Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfce1f2a15 | |||
| e01074926e |
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Nexus — environment variable reference
|
# CapaKraken — environment variable reference
|
||||||
#
|
#
|
||||||
# Copy this file to .env and fill in the values before running the app.
|
# Copy this file to .env and fill in the values before running the app.
|
||||||
# Lines starting with # are comments. Lines with no value are optional.
|
# Lines starting with # are comments. Lines with no value are optional.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
# REQUIRED — Public URL of the app (with scheme, no trailing slash).
|
# REQUIRED — Public URL of the app (with scheme, no trailing slash).
|
||||||
# Used in email links (invites, password reset) and as the Auth.js base URL.
|
# Used in email links (invites, password reset) and as the Auth.js base URL.
|
||||||
# Must use https:// in production.
|
# Must use https:// in production.
|
||||||
NEXTAUTH_URL=https://nexus.example.com
|
NEXTAUTH_URL=https://capakraken.example.com
|
||||||
|
|
||||||
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
|
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
|
||||||
# Generate one with: openssl rand -base64 32
|
# Generate one with: openssl rand -base64 32
|
||||||
@@ -65,7 +65,7 @@ REDIS_PASSWORD=
|
|||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
# SMTP_USER=no-reply@example.com
|
# SMTP_USER=no-reply@example.com
|
||||||
# SMTP_PASSWORD=
|
# SMTP_PASSWORD=
|
||||||
# SMTP_FROM=Nexus <no-reply@example.com>
|
# SMTP_FROM=CapaKraken <no-reply@example.com>
|
||||||
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
|
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
|
||||||
|
|
||||||
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
|
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
|
||||||
@@ -74,8 +74,8 @@ REDIS_PASSWORD=
|
|||||||
# Used as the password for the pgAdmin web UI (http://localhost:5050).
|
# Used as the password for the pgAdmin web UI (http://localhost:5050).
|
||||||
PGADMIN_PASSWORD=
|
PGADMIN_PASSWORD=
|
||||||
|
|
||||||
# Email shown on the pgAdmin login screen (default: admin@nexus.dev).
|
# Email shown on the pgAdmin login screen (default: admin@capakraken.dev).
|
||||||
# PGADMIN_EMAIL=admin@nexus.dev
|
# PGADMIN_EMAIL=admin@capakraken.dev
|
||||||
|
|
||||||
# ─── Logging ─────────────────────────────────────────────────────────────────
|
# ─── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ Absolute Pfade unter `/share/Container/gitea/` sind **außerhalb** der Container
|
|||||||
|
|
||||||
## Repo-Secrets für CI/CD
|
## Repo-Secrets für CI/CD
|
||||||
|
|
||||||
Im nexus-Repo → **Settings → Actions → Secrets** eintragen:
|
Im capakraken-Repo → **Settings → Actions → Secrets** eintragen:
|
||||||
|
|
||||||
| Secret | Zweck |
|
| Secret | Zweck |
|
||||||
| ----------------------- | -------------------------------------- |
|
| ----------------------- | -------------------------------------- |
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability in Nexus, please report it responsibly.
|
If you discover a security vulnerability in CapaKraken, please report it responsibly.
|
||||||
|
|
||||||
**Do not** open a public GitHub issue for security vulnerabilities.
|
**Do not** open a public GitHub issue for security vulnerabilities.
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -114,7 +114,7 @@ jobs:
|
|||||||
run: pnpm db:generate
|
run: pnpm db:generate
|
||||||
|
|
||||||
- name: Run assistant split regression
|
- name: Run assistant split regression
|
||||||
run: pnpm --filter @nexus/api test:assistant-split
|
run: pnpm --filter @capakraken/api test:assistant-split
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Lint — ~20s, no services needed
|
# Lint — ~20s, no services needed
|
||||||
@@ -204,13 +204,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests with coverage
|
- name: Run unit tests with coverage
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter @nexus/web test:unit -- --coverage
|
pnpm --filter @capakraken/web test:unit -- --coverage
|
||||||
pnpm --filter @nexus/engine exec vitest run --coverage
|
pnpm --filter @capakraken/engine exec vitest run --coverage
|
||||||
pnpm --filter @nexus/staffing exec vitest run --coverage
|
pnpm --filter @capakraken/staffing exec vitest run --coverage
|
||||||
pnpm --filter @nexus/api exec vitest run --coverage
|
pnpm --filter @capakraken/api exec vitest run --coverage
|
||||||
pnpm --filter @nexus/application exec vitest run --coverage
|
pnpm --filter @capakraken/application exec vitest run --coverage
|
||||||
pnpm --filter @nexus/shared exec vitest run --coverage
|
pnpm --filter @capakraken/shared exec vitest run --coverage
|
||||||
pnpm --filter @nexus/db test:unit
|
pnpm --filter @capakraken/db test:unit
|
||||||
|
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -274,7 +274,7 @@ jobs:
|
|||||||
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm --filter @nexus/web exec next build
|
run: pnpm --filter @capakraken/web exec next build
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# E2E — depends on build, needs PostgreSQL + Redis
|
# E2E — depends on build, needs PostgreSQL + Redis
|
||||||
@@ -364,11 +364,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: pnpm --filter @nexus/web exec playwright install --with-deps chromium
|
run: pnpm --filter @capakraken/web exec playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Install Playwright system deps
|
- name: Install Playwright system deps
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
run: pnpm --filter @nexus/web exec playwright install-deps chromium
|
run: pnpm --filter @capakraken/web exec playwright install-deps chromium
|
||||||
|
|
||||||
- name: Install psql (debug schema state)
|
- name: Install psql (debug schema state)
|
||||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
|
||||||
@@ -416,7 +416,7 @@ jobs:
|
|||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
|
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
|
||||||
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
|
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
|
||||||
echo "--- prisma db push ---"
|
echo "--- prisma db push ---"
|
||||||
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
DATABASE_URL="$PINNED_URL" pnpm --filter @capakraken/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
||||||
echo "--- tables in public after push ---"
|
echo "--- tables in public after push ---"
|
||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
|
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
|
||||||
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
||||||
@@ -438,7 +438,7 @@ jobs:
|
|||||||
# and restarts mid-run, producing cascading ECONNREFUSED failures
|
# and restarts mid-run, producing cascading ECONNREFUSED failures
|
||||||
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
|
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
|
||||||
# is run locally / in a dedicated nightly job.
|
# is run locally / in a dedicated nightly job.
|
||||||
run: pnpm --filter @nexus/web exec playwright test e2e/smoke.spec.ts
|
run: pnpm --filter @capakraken/web exec playwright test e2e/smoke.spec.ts
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -576,7 +576,7 @@ jobs:
|
|||||||
ln -sfn /app/packages/db/node_modules/@prisma /app/scripts/node_modules/@prisma
|
ln -sfn /app/packages/db/node_modules/@prisma /app/scripts/node_modules/@prisma
|
||||||
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
|
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
|
||||||
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
|
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
|
||||||
node /app/scripts/setup-admin.mjs --email admin@nexus.dev --name Admin --password admin123
|
node /app/scripts/setup-admin.mjs --email admin@capakraken.dev --name Admin --password admin123
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Set up Node.js 20
|
- name: Set up Node.js 20
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Nexus
|
# CapaKraken
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Nexus ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Nexus ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produk
|
|||||||
## Monorepo-Struktur
|
## Monorepo-Struktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
nexus/
|
capakraken/
|
||||||
├── apps/web
|
├── apps/web
|
||||||
├── packages/shared
|
├── packages/shared
|
||||||
├── packages/db
|
├── packages/db
|
||||||
@@ -41,7 +41,7 @@ nexus/
|
|||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
- `pnpm test:unit`
|
- `pnpm test:unit`
|
||||||
- `pnpm --filter @nexus/web exec tsc --noEmit`
|
- `pnpm --filter @capakraken/web exec tsc --noEmit`
|
||||||
- `pnpm lint`
|
- `pnpm lint`
|
||||||
|
|
||||||
## Dokumente
|
## Dokumente
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @nexus/db db:generate
|
RUN pnpm --filter @capakraken/db db:generate
|
||||||
|
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -39,7 +39,7 @@ COPY --from=deps /app/ ./
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @nexus/db db:generate
|
RUN pnpm --filter @capakraken/db db:generate
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -63,7 +63,7 @@ RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
|
|||||||
AUTH_SECRET="$AUTH_SECRET" \
|
AUTH_SECRET="$AUTH_SECRET" \
|
||||||
DATABASE_URL="$DATABASE_URL" \
|
DATABASE_URL="$DATABASE_URL" \
|
||||||
REDIS_URL="$REDIS_URL" \
|
REDIS_URL="$REDIS_URL" \
|
||||||
pnpm --filter @nexus/web build
|
pnpm --filter @capakraken/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 3: Migration runner
|
# Stage 3: Migration runner
|
||||||
@@ -72,7 +72,7 @@ FROM builder AS migrator
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["pnpm", "--filter", "@nexus/db", "db:migrate:deploy"]
|
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"]
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 4: Production runtime
|
# Stage 4: Production runtime
|
||||||
|
|||||||
+18
-69
@@ -1,7 +1,6 @@
|
|||||||
# Nexus – Projekt-Learnings
|
# CapaKraken – Projekt-Learnings
|
||||||
|
|
||||||
## Format
|
## Format
|
||||||
|
|
||||||
**Datum | Kategorie | Problem → Lösung**
|
**Datum | Kategorie | Problem → Lösung**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -13,13 +12,11 @@
|
|||||||
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
|
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
|
||||||
|
|
||||||
**Solution — split config pattern:**
|
**Solution — split config pattern:**
|
||||||
|
|
||||||
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
|
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
|
||||||
- `auth-edge.ts` — `NextAuth(authConfig)` with the lean config; used only by middleware
|
- `auth-edge.ts` — `NextAuth(authConfig)` with the lean config; used only by middleware
|
||||||
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
|
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
|
||||||
|
|
||||||
**Middleware wrapping:**
|
**Middleware wrapping:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { auth } from "./server/auth-edge.js";
|
import { auth } from "./server/auth-edge.js";
|
||||||
export default auth(function middleware(request) {
|
export default auth(function middleware(request) {
|
||||||
@@ -31,19 +28,17 @@ export default auth(function middleware(request) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Three-layer defence:**
|
**Three-layer defence:**
|
||||||
|
|
||||||
1. Middleware — server-side redirect before page renders
|
1. Middleware — server-side redirect before page renders
|
||||||
2. `SessionGuard` client component — `useSession()` → `router.replace()` on SPA navigation
|
2. `SessionGuard` client component — `useSession()` → `router.replace()` on SPA navigation
|
||||||
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
|
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
|
||||||
|
|
||||||
**Test mock pattern for middleware tests:**
|
**Test mock pattern for middleware tests:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
vi.mock("./server/auth-edge.js", () => ({
|
vi.mock("./server/auth-edge.js", () => ({
|
||||||
auth: (handler) => (req) => handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
auth: (handler) => (req) =>
|
||||||
|
handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
|
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,14 +50,12 @@ Needed because `vi.resetModules()` inside the helper function doesn't re-apply t
|
|||||||
**Repo path:** `Hartmut/plANARCHY`
|
**Repo path:** `Hartmut/plANARCHY`
|
||||||
|
|
||||||
Usage example (list open issues):
|
Usage example (list open issues):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
|
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
|
||||||
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
|
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
|
||||||
```
|
```
|
||||||
|
|
||||||
Close an issue with a comment:
|
Close an issue with a comment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(cat ~/.gitea-token)
|
TOKEN=$(cat ~/.gitea-token)
|
||||||
REPO="Hartmut/plANARCHY"
|
REPO="Hartmut/plANARCHY"
|
||||||
@@ -82,22 +75,18 @@ curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/
|
|||||||
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
|
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
|
||||||
|
|
||||||
**Solution:** Always restart the app container after Prisma schema changes:
|
**Solution:** Always restart the app container after Prisma schema changes:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose --profile full restart app
|
docker compose --profile full restart app
|
||||||
```
|
```
|
||||||
|
|
||||||
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
|
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
|
||||||
|
|
||||||
**Rule:** Prisma schema change checklist:
|
**Rule:** Prisma schema change checklist:
|
||||||
|
|
||||||
1. Edit `packages/db/prisma/schema.prisma`
|
1. Edit `packages/db/prisma/schema.prisma`
|
||||||
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
|
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
|
||||||
3. Apply migration to the running DB directly (for dev speed): `docker exec nexus-postgres-1 psql -U nexus -d nexus < migration.sql`
|
3. Apply migration to the running DB directly (for dev speed): `docker exec capakraken-postgres-1 psql -U capakraken -d capakraken < migration.sql`
|
||||||
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
|
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
|
||||||
|
|
||||||
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
|
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
|
||||||
|
|
||||||
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
|
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
|
||||||
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
|
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
|
||||||
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
|
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
|
||||||
@@ -106,28 +95,24 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
|
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
|
||||||
|
|
||||||
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
|
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
|
||||||
|
|
||||||
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
|
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
|
||||||
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
|
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
|
||||||
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
|
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
|
||||||
|
|
||||||
### 2026-03-12 | Build | NextAuth portable export typing
|
### 2026-03-12 | Build | NextAuth portable export typing
|
||||||
|
|
||||||
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
|
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
|
||||||
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
|
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
|
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
|
||||||
|
|
||||||
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
|
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
|
||||||
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
|
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
|
||||||
- `packages/application` depends on `@nexus/db`, `@nexus/engine`, `@nexus/shared`; `packages/api` depends on `@nexus/application`
|
- `packages/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application`
|
||||||
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
|
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
|
||||||
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
|
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
|
||||||
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
|
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
|
||||||
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
|
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
|
||||||
|
|
||||||
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
|
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
|
||||||
|
|
||||||
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
|
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
|
||||||
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
|
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
|
||||||
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
|
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
|
||||||
@@ -135,14 +120,12 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
|
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
|
||||||
|
|
||||||
### 2026-03-12 | Architecture | Estimating foundation slice
|
### 2026-03-12 | Architecture | Estimating foundation slice
|
||||||
|
|
||||||
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
|
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
|
||||||
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
|
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
|
||||||
- Shared estimate enums/types/schemas now live in `@nexus/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@nexus/application`.
|
- Shared estimate enums/types/schemas now live in `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`.
|
||||||
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
|
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
|
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
|
||||||
|
|
||||||
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
|
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
|
||||||
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
|
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
|
||||||
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
|
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
|
||||||
@@ -151,55 +134,45 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
|
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
|
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
|
||||||
|
|
||||||
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
|
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
|
||||||
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
|
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
|
||||||
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
|
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
|
||||||
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
|
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
|
||||||
|
|
||||||
### 2026-03-11 | Security | Phase 0 critical fixes
|
### 2026-03-11 | Security | Phase 0 critical fixes
|
||||||
|
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution.
|
||||||
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@nexus/db` is not enough for TS resolution.
|
|
||||||
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
|
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
|
||||||
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
|
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
|
||||||
- `@nexus/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
- `@capakraken/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
||||||
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
|
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
|
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
|
||||||
|
|
||||||
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
|
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
|
||||||
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
|
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
|
||||||
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
|
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
|
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
|
||||||
|
|
||||||
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
|
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
|
||||||
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
|
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | recomputeValueScores sequential updates
|
### 2026-03-09 | Performance | recomputeValueScores sequential updates
|
||||||
|
|
||||||
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
|
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
|
||||||
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
|
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
|
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
|
||||||
|
|
||||||
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
|
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
|
||||||
**Fix:** Use `ctx.dbUser?.id` directly.
|
**Fix:** Use `ctx.dbUser?.id` directly.
|
||||||
|
|
||||||
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
|
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
|
||||||
|
|
||||||
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
|
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
|
||||||
|
|
||||||
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
|
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
|
||||||
|
|
||||||
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
|
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
|
||||||
|
|
||||||
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
|
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
|
||||||
|
|
||||||
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
|
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
|
||||||
|
|
||||||
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
|
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
|
||||||
|
|
||||||
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
|
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
|
||||||
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
|
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
|
||||||
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
|
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
|
||||||
@@ -207,7 +180,6 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
|
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
|
||||||
|
|
||||||
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
|
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
|
||||||
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
|
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
|
||||||
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin.
|
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin.
|
||||||
@@ -215,7 +187,6 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
|
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
|
||||||
|
|
||||||
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
|
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
|
||||||
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
|
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
|
||||||
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
|
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
|
||||||
@@ -223,22 +194,19 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
|
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
|
||||||
|
|
||||||
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
|
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
|
||||||
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
|
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
|
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
|
||||||
|
|
||||||
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
|
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
|
||||||
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/nexus`) verschieben.
|
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben.
|
||||||
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
|
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
|
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
|
||||||
|
|
||||||
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
|
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
|
||||||
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
|
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
|
||||||
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
|
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
|
||||||
@@ -246,7 +214,6 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
|
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
|
||||||
|
|
||||||
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
|
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
|
||||||
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
|
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
|
||||||
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
|
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
|
||||||
@@ -254,7 +221,6 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
|
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
|
||||||
|
|
||||||
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab.
|
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab.
|
||||||
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
|
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
|
||||||
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
|
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
|
||||||
@@ -262,10 +228,8 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
|
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
|
||||||
|
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind.
|
||||||
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@nexus/shared`-Enums kompatibel sind.
|
|
||||||
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
|
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
|
||||||
|
|
||||||
- `project as unknown as Project`
|
- `project as unknown as Project`
|
||||||
- `form.orderType as unknown as OrderType`
|
- `form.orderType as unknown as OrderType`
|
||||||
- `resource.skills as unknown as SkillEntry[]`
|
- `resource.skills as unknown as SkillEntry[]`
|
||||||
@@ -273,7 +237,6 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | SSE statt WebSockets
|
### 2026-02-xx | Architektur | SSE statt WebSockets
|
||||||
|
|
||||||
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
|
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
|
||||||
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
|
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
|
||||||
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
|
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
|
||||||
@@ -284,21 +247,20 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
|
|
||||||
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar.
|
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar.
|
||||||
**Lösung:** Schrittweise Extraktion:
|
**Lösung:** Schrittweise Extraktion:
|
||||||
|
|
||||||
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
|
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
|
||||||
2. Heatmap-Utilities → `heatmapUtils.ts`
|
2. Heatmap-Utilities → `heatmapUtils.ts`
|
||||||
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
|
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
|
||||||
4. Header-JSX → `TimelineHeader.tsx`
|
4. Header-JSX → `TimelineHeader.tsx`
|
||||||
5. Toolbar-JSX → `TimelineToolbar.tsx`
|
5. Toolbar-JSX → `TimelineToolbar.tsx`
|
||||||
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
||||||
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
|
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
|
||||||
|
|
||||||
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
|
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
|
||||||
**Lösung:** `ioredis` in `@nexus/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `nexus:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
**Lösung:** `ioredis` in `@capakraken/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `capakraken:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
||||||
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5.
|
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5.
|
||||||
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
|
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
|
||||||
|
|
||||||
@@ -378,12 +340,10 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
|
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
|
||||||
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
|
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
|
||||||
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
|
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const adminHash = await hash("admin123");
|
const adminHash = await hash("admin123");
|
||||||
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
|
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
|
||||||
```
|
```
|
||||||
|
|
||||||
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
|
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -423,20 +383,18 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
|
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
|
||||||
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
|
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
|
||||||
**Lösung (zweistufig):**
|
**Lösung (zweistufig):**
|
||||||
|
|
||||||
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
|
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
|
||||||
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
|
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
|
||||||
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
||||||
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
|
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
|
||||||
|
|
||||||
**Kontext:** Nexus hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
**Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
||||||
|
|
||||||
**Lösung:** Zweigeteiltes System:
|
**Lösung:** Zweigeteiltes System:
|
||||||
|
|
||||||
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
|
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
|
||||||
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
|
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
|
||||||
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
|
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
|
||||||
@@ -463,7 +421,6 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Offene Fragen
|
## Offene Fragen
|
||||||
|
|
||||||
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
|
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
|
||||||
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
|
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
|
||||||
- [x] P7.2 Touch-Support → umgesetzt
|
- [x] P7.2 Touch-Support → umgesetzt
|
||||||
@@ -473,7 +430,6 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik
|
### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik
|
||||||
|
|
||||||
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
|
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
|
||||||
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
|
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
|
||||||
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
|
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
|
||||||
@@ -483,17 +439,14 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
|
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
|
||||||
|
|
||||||
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
|
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
|
||||||
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
|
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
|
||||||
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
|
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
|
||||||
**Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
|
**Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
|
||||||
|
|
||||||
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
|
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
|
||||||
|
|
||||||
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
|
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
|
|
||||||
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
|
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
|
||||||
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
|
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
|
||||||
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
|
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
|
||||||
@@ -504,17 +457,15 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
|
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
|
||||||
|
|
||||||
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
|
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
|
||||||
|
|
||||||
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
|
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
|
|
||||||
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
|
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
|
||||||
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
|
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
|
||||||
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
|
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
|
||||||
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
|
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
|
||||||
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
|
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
|
||||||
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
|
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
|
||||||
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
||||||
|
|
||||||
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
|
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
|
||||||
|
|
||||||
@@ -523,13 +474,11 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
**Decision: No code is written until the product decision is made.**
|
**Decision: No code is written until the product decision is made.**
|
||||||
|
|
||||||
The core trade-off is:
|
The core trade-off is:
|
||||||
|
|
||||||
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
|
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
|
||||||
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
|
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
|
||||||
- **Short-lived API tokens (OAuth-style):** Suitable if Nexus exposes a public API. Over-engineered for an internal tool with no current integration story.
|
- **Short-lived API tokens (OAuth-style):** Suitable if CapaKraken exposes a public API. Over-engineered for an internal tool with no current integration story.
|
||||||
|
|
||||||
**Engineering guidance for when the decision is made:**
|
**Engineering guidance for when the decision is made:**
|
||||||
|
|
||||||
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
|
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
|
||||||
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
|
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
|
||||||
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
|
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/screenshots/dashboard-dark.jpeg" alt="Nexus Dashboard" width="100%" />
|
<img src="docs/screenshots/dashboard-dark.jpeg" alt="CapaKraken Dashboard" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Nexus</h1>
|
<h1 align="center">CapaKraken</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Resource & Capacity Planning for 3D Production Studios</strong><br/>
|
<strong>Resource & Capacity Planning for 3D Production Studios</strong><br/>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
Nexus is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
CapaKraken is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
||||||
|
|
||||||
The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
|
The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ The application was designed from the ground up for the unique challenges of cre
|
|||||||
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
|
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
The timeline is the centerpiece of Nexus. It provides a visual, interactive view of all resource allocations across projects.
|
The timeline is the centerpiece of CapaKraken. It provides a visual, interactive view of all resource allocations across projects.
|
||||||
|
|
||||||
- **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
|
- **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
|
||||||
- **Project View** -- flip the perspective to see all resources assigned to each project
|
- **Project View** -- flip the perspective to see all resources assigned to each project
|
||||||
@@ -97,18 +97,18 @@ A structured list view of all allocations with:
|
|||||||
|
|
||||||
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
|
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
|
||||||
|
|
||||||
| Widget | Description |
|
| Widget | Description |
|
||||||
| -------------------------- | --------------------------------------------------------------------------------- |
|
|--------|-------------|
|
||||||
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
||||||
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
||||||
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
||||||
| **Project Overview** | All projects with cost, person days, and status badges |
|
| **Project Overview** | All projects with cost, person days, and status badges |
|
||||||
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
||||||
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
||||||
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
||||||
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
||||||
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
||||||
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
||||||
|
|
||||||
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
|
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
|
||||||
|
|
||||||
@@ -172,23 +172,23 @@ A full project estimation workflow:
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology | Purpose |
|
| Layer | Technology | Purpose |
|
||||||
| -------------------- | --------------------------------- | -------------------------------------------------------- |
|
|-------|-----------|---------|
|
||||||
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
||||||
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
||||||
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
||||||
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
||||||
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
||||||
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
||||||
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
||||||
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
||||||
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
||||||
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
nexus/
|
capakraken/
|
||||||
|
|
|
|
||||||
+-- apps/
|
+-- apps/
|
||||||
| +-- web/ Next.js 15 application (frontend + API routes)
|
| +-- web/ Next.js 15 application (frontend + API routes)
|
||||||
@@ -263,18 +263,18 @@ nexus/
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
| Requirement | Minimum Version | Check |
|
| Requirement | Minimum Version | Check |
|
||||||
| ------------------ | --------------- | ------------------------ |
|
|-------------|----------------|-------|
|
||||||
| **Node.js** | 20.x | `node --version` |
|
| **Node.js** | 20.x | `node --version` |
|
||||||
| **pnpm** | 9.x | `pnpm --version` |
|
| **pnpm** | 9.x | `pnpm --version` |
|
||||||
| **Docker** | 24+ | `docker --version` |
|
| **Docker** | 24+ | `docker --version` |
|
||||||
| **Docker Compose** | v2 | `docker compose version` |
|
| **Docker Compose** | v2 | `docker compose version` |
|
||||||
|
|
||||||
### 1. Clone and configure
|
### 1. Clone and configure
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git nexus
|
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git capakraken
|
||||||
cd nexus
|
cd capakraken
|
||||||
```
|
```
|
||||||
|
|
||||||
Create your environment file:
|
Create your environment file:
|
||||||
@@ -315,13 +315,13 @@ This single command will:
|
|||||||
You'll see output like:
|
You'll see output like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Starting Nexus...
|
Starting CapaKraken...
|
||||||
Starting PostgreSQL + Redis...
|
Starting PostgreSQL + Redis...
|
||||||
Waiting for PostgreSQL...
|
Waiting for PostgreSQL...
|
||||||
Starting app container on port 3100...
|
Starting app container on port 3100...
|
||||||
Waiting for server (up to 90s)...
|
Waiting for server (up to 90s)...
|
||||||
|
|
||||||
Nexus is running!
|
CapaKraken is running!
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
@@ -372,13 +372,13 @@ This populates the database with sample clients, projects, resources, allocation
|
|||||||
|
|
||||||
When running with Docker Compose, the following services are available:
|
When running with Docker Compose, the following services are available:
|
||||||
|
|
||||||
| Service | URL | Purpose |
|
| Service | URL | Purpose |
|
||||||
| -------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|---------|-----|---------|
|
||||||
| **Nexus App** | [localhost:3100](http://localhost:3100) | Main application |
|
| **CapaKraken App** | [localhost:3100](http://localhost:3100) | Main application |
|
||||||
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
||||||
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
||||||
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `nexus`, db: `nexus`) |
|
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `capakraken`, db: `capakraken`) |
|
||||||
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -386,50 +386,50 @@ When running with Docker Compose, the following services are available:
|
|||||||
|
|
||||||
### Application Lifecycle
|
### Application Lifecycle
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------- | ------------------------------------------- |
|
|---------|-------------|
|
||||||
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
||||||
| `bash scripts/stop.sh` | Stop all services gracefully |
|
| `bash scripts/stop.sh` | Stop all services gracefully |
|
||||||
| `bash scripts/restart.sh` | Full stop + start cycle |
|
| `bash scripts/restart.sh` | Full stop + start cycle |
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------- | -------------------------------------------------------- |
|
|---------|-------------|
|
||||||
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
||||||
| `pnpm build` | Production build (standalone output) |
|
| `pnpm build` | Production build (standalone output) |
|
||||||
| `pnpm lint` | Run ESLint across all packages |
|
| `pnpm lint` | Run ESLint across all packages |
|
||||||
| `pnpm format` | Format all files with Prettier |
|
| `pnpm format` | Format all files with Prettier |
|
||||||
| `pnpm test:unit` | Run unit tests via Vitest |
|
| `pnpm test:unit` | Run unit tests via Vitest |
|
||||||
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
||||||
| `pnpm typecheck` | TypeScript type checking across all packages |
|
| `pnpm typecheck` | TypeScript type checking across all packages |
|
||||||
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
|
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --------------------- | ------------------------------------------------ |
|
|---------|-------------|
|
||||||
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
||||||
| `pnpm db:migrate` | Create and apply new migrations |
|
| `pnpm db:migrate` | Create and apply new migrations |
|
||||||
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
||||||
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
||||||
| `pnpm db:seed` | Seed the database with demo data |
|
| `pnpm db:seed` | Seed the database with demo data |
|
||||||
| `pnpm db:doctor` | Run health checks on database state |
|
| `pnpm db:doctor` | Run health checks on database state |
|
||||||
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
||||||
| `pnpm db:seed:import` | Import a previously exported seed file |
|
| `pnpm db:seed:import` | Import a previously exported seed file |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
Nexus ships with a production-ready Docker Compose stack and deployment automation.
|
CapaKraken ships with a production-ready Docker Compose stack and deployment automation.
|
||||||
|
|
||||||
### Quick Deploy
|
### Quick Deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configure required secrets
|
# Configure required secrets
|
||||||
export APP_IMAGE=ghcr.io/your-org/nexus-app:latest
|
export APP_IMAGE=ghcr.io/your-org/capakraken-app:latest
|
||||||
export MIGRATOR_IMAGE=ghcr.io/your-org/nexus-migrator:latest
|
export MIGRATOR_IMAGE=ghcr.io/your-org/capakraken-migrator:latest
|
||||||
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||||
export REDIS_PASSWORD=$(openssl rand -hex 32)
|
export REDIS_PASSWORD=$(openssl rand -hex 32)
|
||||||
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
||||||
@@ -454,35 +454,35 @@ bash tooling/deploy/deploy-compose.sh production
|
|||||||
|
|
||||||
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
|
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
| ---------------------- | -------- | -------------------- | ----------------------------------------------- |
|
|----------|----------|---------|-------------|
|
||||||
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
||||||
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
||||||
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
||||||
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
||||||
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
||||||
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
||||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||||
| `SMTP_FROM` | No | `noreply@nexus.dev` | Sender address for outgoing emails |
|
| `SMTP_FROM` | No | `noreply@capakraken.dev` | Sender address for outgoing emails |
|
||||||
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
||||||
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
||||||
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
||||||
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
| Principle | Implementation |
|
| Principle | Implementation |
|
||||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|-----------|---------------|
|
||||||
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
||||||
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
||||||
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
||||||
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
||||||
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
||||||
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
||||||
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
||||||
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
|
|||||||
4. Run quality gates before submitting:
|
4. Run quality gates before submitting:
|
||||||
```bash
|
```bash
|
||||||
pnpm test:unit
|
pnpm test:unit
|
||||||
pnpm --filter @nexus/web exec tsc --noEmit
|
pnpm --filter @capakraken/web exec tsc --noEmit
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm check:architecture
|
pnpm check:architecture
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from "./a11y-fixture.js";
|
|||||||
test.describe("Accessibility (axe-core)", () => {
|
test.describe("Accessibility (axe-core)", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
||||||
|
|||||||
+14
-17
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Admin Pages", () => {
|
test.describe("Admin Pages", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -12,42 +12,39 @@ test.describe("Admin Pages", () => {
|
|||||||
test("settings page loads", async ({ page }) => {
|
test("settings page loads", async ({ page }) => {
|
||||||
await page.goto("/admin/settings");
|
await page.goto("/admin/settings");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({
|
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("users page loads with user list", async ({ page }) => {
|
test("users page loads with user list", async ({ page }) => {
|
||||||
await page.goto("/admin/users");
|
await page.goto("/admin/users");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({
|
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
// Should show a table with at least the admin user
|
// Should show a table with at least the admin user
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator("text=admin@nexus.dev")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("text=admin@capakraken.dev")).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("roles page loads", async ({ page }) => {
|
test("roles page loads", async ({ page }) => {
|
||||||
await page.goto("/roles");
|
await page.goto("/roles");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1").filter({ hasText: /Roles/i })).toBeVisible({ timeout: 10000 });
|
await expect(
|
||||||
|
page.locator("h1").filter({ hasText: /Roles/i }),
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
// Should show table or list of roles
|
// Should show table or list of roles
|
||||||
await expect(page.locator("table").or(page.locator("text=No roles"))).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("table").or(page.locator("text=No roles")),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blueprints page loads", async ({ page }) => {
|
test("blueprints page loads", async ({ page }) => {
|
||||||
await page.goto("/admin/blueprints");
|
await page.goto("/admin/blueprints");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1").filter({ hasText: /Blueprints/i })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1").filter({ hasText: /Blueprints/i }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
// Should show blueprint cards or list from seed data
|
// Should show blueprint cards or list from seed data
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("table")
|
||||||
.locator("table")
|
|
||||||
.or(page.locator("text=3D Content Production"))
|
.or(page.locator("text=3D Content Production"))
|
||||||
.or(page.locator("text=No blueprints")),
|
.or(page.locator("text=No blueprints")),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -40,28 +40,24 @@ async function signIn(page: Page, email: string, password: string) {
|
|||||||
test.describe("Allocations", () => {
|
test.describe("Allocations", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await freezeBrowserTime(page);
|
await freezeBrowserTime(page);
|
||||||
await signIn(page, "admin@nexus.dev", "admin123");
|
await signIn(page, "admin@capakraken.dev", "admin123");
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({
|
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
||||||
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicitly restrictive filters show a visible empty state and can be reset", async ({
|
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const projectFilter = page.getByPlaceholder("Filter by project…");
|
const projectFilter = page.getByPlaceholder("Filter by project…");
|
||||||
@@ -87,23 +83,21 @@ test.describe("Allocations", () => {
|
|||||||
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
||||||
await newBtn.click();
|
await newBtn.click();
|
||||||
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
|
||||||
page.getByRole("heading", { name: /New (Assignment|Open Demand)/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter by status works", async ({ page }) => {
|
test("filter by status works", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Look for status filter chips or dropdown
|
// Look for status filter chips or dropdown
|
||||||
const statusFilter = page
|
const statusFilter = page.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i }).first();
|
||||||
.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i })
|
|
||||||
.first();
|
|
||||||
if ((await statusFilter.count()) > 0) {
|
if ((await statusFilter.count()) > 0) {
|
||||||
await statusFilter.click();
|
await statusFilter.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
// After clicking a status filter, the page should still show the table
|
// After clicking a status filter, the page should still show the table
|
||||||
await expect(page.locator("table").or(page.locator("text=No allocations"))).toBeVisible();
|
await expect(
|
||||||
|
page.locator("table").or(page.locator("text=No allocations")),
|
||||||
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,17 +108,17 @@ test.describe("Allocations", () => {
|
|||||||
await colToggle.click();
|
await colToggle.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
// A panel or dropdown with column checkboxes should appear
|
// A panel or dropdown with column checkboxes should appear
|
||||||
await expect(page.locator("input[type='checkbox']").first()).toBeVisible({ timeout: 3000 });
|
await expect(
|
||||||
|
page.locator("input[type='checkbox']").first(),
|
||||||
|
).toBeVisible({ timeout: 3000 });
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("viewer sees a visible access error instead of an empty allocations page", async ({
|
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
|
||||||
browser,
|
|
||||||
}) => {
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await freezeBrowserTime(page);
|
await freezeBrowserTime(page);
|
||||||
await signIn(page, "viewer@nexus.dev", "viewer123");
|
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -71,8 +71,8 @@ test.describe("Analytics / Insights", () => {
|
|||||||
test("insights page loads without errors", async ({ page }) => {
|
test("insights page loads without errors", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Page should render some heading or content area — not a hard 404
|
// Page should render some heading or content area — not a hard 404
|
||||||
await expect(page.locator("h1").or(page.locator("main")).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1").or(page.locator("main")).first(),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const ADMIN_EMAIL = "admin@nexus.dev";
|
const ADMIN_EMAIL = "admin@capakraken.dev";
|
||||||
const ADMIN_PASSWORD = "admin123";
|
const ADMIN_PASSWORD = "admin123";
|
||||||
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
||||||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||||||
@@ -159,9 +159,7 @@ test.describe("Assistant approvals", () => {
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({
|
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const suffix = Date.now();
|
const suffix = Date.now();
|
||||||
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
||||||
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
||||||
@@ -212,22 +210,14 @@ test.describe("Assistant approvals", () => {
|
|||||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||||
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
||||||
|
|
||||||
const currentCard = page
|
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
|
||||||
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]')
|
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
|
||||||
.first();
|
|
||||||
const otherCard = page
|
|
||||||
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]')
|
|
||||||
.first();
|
|
||||||
await expect(currentCard).toContainText("This chat");
|
await expect(currentCard).toContainText("This chat");
|
||||||
await expect(otherCard).toContainText("Other chat");
|
await expect(otherCard).toContainText("Other chat");
|
||||||
|
|
||||||
await otherCard.getByTestId("assistant-approval-cancel").click();
|
await otherCard.getByTestId("assistant-approval-cancel").click();
|
||||||
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
||||||
await expect(
|
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
|
||||||
page.locator(
|
|
||||||
`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`,
|
|
||||||
),
|
|
||||||
).toHaveCount(0);
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test.describe("Authentication", () => {
|
|||||||
|
|
||||||
test("admin can sign in", async ({ page }) => {
|
test("admin can sign in", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,7 +16,9 @@ test.describe("Bench Board", () => {
|
|||||||
|
|
||||||
test("bench board page loads with heading", async ({ page }) => {
|
test("bench board page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: "Bench Board" })).toBeVisible({ timeout: 10000 });
|
await expect(
|
||||||
|
page.locator("h1", { hasText: "Bench Board" }),
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("date range filter inputs are visible", async ({ page }) => {
|
test("date range filter inputs are visible", async ({ page }) => {
|
||||||
@@ -30,8 +32,7 @@ test.describe("Bench Board", () => {
|
|||||||
test("shows bench results or no-resources empty state", async ({ page }) => {
|
test("shows bench results or no-resources empty state", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("table")
|
||||||
.locator("table")
|
|
||||||
.or(page.locator("text=No resources on bench"))
|
.or(page.locator("text=No resources on bench"))
|
||||||
.or(page.locator("text=No results"))
|
.or(page.locator("text=No results"))
|
||||||
.first(),
|
.first(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Dashboard", () => {
|
test.describe("Dashboard", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -31,9 +31,7 @@ test.describe("Dashboard", () => {
|
|||||||
const addBtn = page.locator("button", { hasText: /Add Widget/i });
|
const addBtn = page.locator("button", { hasText: /Add Widget/i });
|
||||||
if ((await addBtn.count()) > 0) {
|
if ((await addBtn.count()) > 0) {
|
||||||
await addBtn.click();
|
await addBtn.click();
|
||||||
await expect(
|
await expect(page.locator("text=Add Widget").or(page.locator("text=Available Widgets"))).toBeVisible();
|
||||||
page.locator("text=Add Widget").or(page.locator("text=Available Widgets")),
|
|
||||||
).toBeVisible();
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,16 +21,9 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
|||||||
|
|
||||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TrpcResult = {
|
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
||||||
result?: { data?: unknown };
|
|
||||||
error?: { data?: { code?: string }; message?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
async function trpcMutation(
|
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||||
page: Page,
|
|
||||||
procedure: string,
|
|
||||||
input: unknown = null,
|
|
||||||
): Promise<TrpcResult> {
|
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||||
@@ -46,11 +39,7 @@ async function trpcMutation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcQuery(
|
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||||
page: Page,
|
|
||||||
procedure: string,
|
|
||||||
input: unknown = null,
|
|
||||||
): Promise<TrpcResult> {
|
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||||
@@ -71,7 +60,7 @@ async function enableMfaForSession(page: Page): Promise<TOTP> {
|
|||||||
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
||||||
|
|
||||||
const totp = new TOTP({
|
const totp = new TOTP({
|
||||||
issuer: "Nexus",
|
issuer: "CapaKraken",
|
||||||
algorithm: "SHA1",
|
algorithm: "SHA1",
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
@@ -103,9 +92,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
// Clean up: disable MFA if a test enabled it
|
// Clean up: disable MFA if a test enabled it
|
||||||
if (totp) {
|
if (totp) {
|
||||||
await disableMfaForSession(page).catch(() => {
|
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */});
|
||||||
/* already disabled or admin override */
|
|
||||||
});
|
|
||||||
totp = null;
|
totp = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -119,7 +106,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
|
|
||||||
expect(data?.secret).toBeTruthy();
|
expect(data?.secret).toBeTruthy();
|
||||||
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
||||||
expect(data?.uri).toContain("Nexus");
|
expect(data?.uri).toContain("CapaKraken");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
||||||
@@ -150,9 +137,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Click the enable/setup button if MFA is not yet enabled
|
// Click the enable/setup button if MFA is not yet enabled
|
||||||
const setupBtn = page
|
const setupBtn = page.getByRole("button", { name: /set up/i }).or(
|
||||||
.getByRole("button", { name: /set up/i })
|
page.getByRole("button", { name: /enable.*mfa/i }),
|
||||||
.or(page.getByRole("button", { name: /enable.*mfa/i }));
|
);
|
||||||
|
|
||||||
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
await setupBtn.click();
|
await setupBtn.click();
|
||||||
@@ -246,10 +233,9 @@ test.describe("MFA — login flow", () => {
|
|||||||
|
|
||||||
// Should show error and remain on TOTP step
|
// Should show error and remain on TOTP step
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.getByText(/invalid.*code|incorrect.*token|try again/i).or(
|
||||||
.getByText(/invalid.*code|incorrect.*token|try again/i)
|
page.locator("[data-error]"),
|
||||||
.or(page.locator("[data-error]"))
|
).first(),
|
||||||
.first(),
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Should NOT have navigated away
|
// Should NOT have navigated away
|
||||||
@@ -262,9 +248,7 @@ test.describe("MFA — login flow", () => {
|
|||||||
test.describe("MFA — users without MFA enabled", () => {
|
test.describe("MFA — users without MFA enabled", () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
|
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
|
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* pnpm --filter @nexus/web exec playwright test \
|
* pnpm --filter @capakraken/web exec playwright test \
|
||||||
* --config playwright.dev.config.ts \
|
* --config playwright.dev.config.ts \
|
||||||
* e2e/dev-system/nav-smoke.spec.ts
|
* e2e/dev-system/nav-smoke.spec.ts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ test.describe("RBAC — admin routes (admin session)", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
// Seed users have planarchy.dev or nexus.dev email domains
|
// Seed users have planarchy.dev or capakraken.dev email domains
|
||||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
||||||
@@ -99,10 +99,9 @@ test.describe("RBAC — allocations permitted for admin", () => {
|
|||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
await expect(
|
||||||
0,
|
page.locator("text=/do not have permission to view allocations/i"),
|
||||||
{ timeout: 8000 },
|
).toHaveCount(0, { timeout: 8000 });
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,10 +112,9 @@ test.describe("RBAC — allocations permitted for manager", () => {
|
|||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
await expect(
|
||||||
0,
|
page.locator("text=/do not have permission to view allocations/i"),
|
||||||
{ timeout: 8000 },
|
).toHaveCount(0, { timeout: 8000 });
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,26 +10,22 @@ async function signIn(page: Page, email: string, password: string) {
|
|||||||
|
|
||||||
test.describe("Estimates", () => {
|
test.describe("Estimates", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await signIn(page, "admin@nexus.dev", "admin123");
|
await signIn(page, "admin@capakraken.dev", "admin123");
|
||||||
await page.goto("/estimates");
|
await page.goto("/estimates");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("estimate list loads", async ({ page }) => {
|
test("estimate list loads", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.getByRole("heading", { name: /estimate workspace/i })).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(page.getByPlaceholder("Search by estimate or opportunity")).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.getByRole("heading", { name: /estimate workspace/i }),
|
||||||
.locator("text=No estimates yet")
|
).toBeVisible({ timeout: 10000 });
|
||||||
.or(
|
await expect(
|
||||||
page.locator(
|
page.getByPlaceholder("Search by estimate or opportunity"),
|
||||||
"text=Select an estimate to inspect the current version, demand lines, and summary metrics.",
|
).toBeVisible({ timeout: 10000 });
|
||||||
),
|
await expect(
|
||||||
),
|
page.locator("text=No estimates yet").or(
|
||||||
|
page.locator("text=Select an estimate to inspect the current version, demand lines, and summary metrics."),
|
||||||
|
),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,13 +44,8 @@ test.describe("Estimates", () => {
|
|||||||
await page.locator("button", { hasText: /New Estimate/i }).click();
|
await page.locator("button", { hasText: /New Estimate/i }).click();
|
||||||
|
|
||||||
// Step 1: Setup — fill a name
|
// Step 1: Setup — fill a name
|
||||||
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({
|
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({ timeout: 5000 });
|
||||||
timeout: 5000,
|
const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
||||||
});
|
|
||||||
const nameInput = page
|
|
||||||
.locator('input[placeholder*="name"]')
|
|
||||||
.or(page.locator('input[name="name"]'))
|
|
||||||
.first();
|
|
||||||
if ((await nameInput.count()) > 0) {
|
if ((await nameInput.count()) > 0) {
|
||||||
await nameInput.fill(`E2E Estimate ${Date.now()}`);
|
await nameInput.fill(`E2E Estimate ${Date.now()}`);
|
||||||
}
|
}
|
||||||
@@ -99,7 +90,9 @@ test.describe("Estimates", () => {
|
|||||||
|
|
||||||
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
|
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("text=No estimates yet")).toBeVisible({ timeout: 10000 });
|
await expect(
|
||||||
|
page.locator("text=No estimates yet"),
|
||||||
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
|
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
|
||||||
@@ -110,14 +103,12 @@ test.describe("Estimates", () => {
|
|||||||
|
|
||||||
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
|
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await signIn(page, "viewer@nexus.dev", "viewer123");
|
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
||||||
await page.goto("/estimates/missing-estimate");
|
await page.goto("/estimates/missing-estimate");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(
|
page.locator("text=Your role can access the estimate list, but not the detailed financial workspace."),
|
||||||
"text=Your role can access the estimate list, but not the detailed financial workspace.",
|
|
||||||
),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Holiday Calendar Editor", () => {
|
test.describe("Holiday Calendar Editor", () => {
|
||||||
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({
|
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const suffix = Date.now().toString();
|
const suffix = Date.now().toString();
|
||||||
const calendarName = `E2E City Calendar ${suffix}`;
|
const calendarName = `E2E City Calendar ${suffix}`;
|
||||||
const holidayName = `E2E Local Holiday ${suffix}`;
|
const holidayName = `E2E Local Holiday ${suffix}`;
|
||||||
@@ -23,18 +21,11 @@ test.describe("Holiday Calendar Editor", () => {
|
|||||||
|
|
||||||
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
||||||
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
||||||
await page
|
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
|
||||||
.getByTestId("holiday-calendar-country-select")
|
|
||||||
.selectOption({ label: "Germany (DE)" });
|
|
||||||
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
||||||
await page.getByTestId("holiday-calendar-create-button").click();
|
await page.getByTestId("holiday-calendar-create-button").click();
|
||||||
|
|
||||||
await expect(
|
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
|
||||||
page
|
|
||||||
.getByTestId(/holiday-calendar-row-/)
|
|
||||||
.filter({ hasText: calendarName })
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
||||||
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
||||||
|
|
||||||
@@ -53,15 +44,10 @@ test.describe("Holiday Calendar Editor", () => {
|
|||||||
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
||||||
await page.getByTestId("holiday-entry-create-button").click();
|
await page.getByTestId("holiday-entry-create-button").click();
|
||||||
|
|
||||||
await expect(
|
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
|
||||||
page.getByText("A holiday entry for this calendar and date already exists"),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
page.once("dialog", (dialog) => dialog.accept());
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
await page
|
await page.getByTestId(/holiday-entry-delete-/).first().click();
|
||||||
.getByTestId(/holiday-entry-delete-/)
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
||||||
|
|
||||||
page.once("dialog", (dialog) => dialog.accept());
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Navigation", () => {
|
test.describe("Navigation", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -28,7 +28,7 @@ test.describe("Navigation", () => {
|
|||||||
|
|
||||||
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
|
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
|
||||||
// Complements the click-based test above with a direct-navigation check
|
// Complements the click-based test above with a direct-navigation check
|
||||||
// covering every sidebar destination. Uses admin@nexus.dev (ADMIN role).
|
// covering every sidebar destination. Uses admin@capakraken.dev (ADMIN role).
|
||||||
const routes = [
|
const routes = [
|
||||||
// Already covered by click test but included for completeness
|
// Already covered by click test but included for completeness
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
@@ -79,10 +79,7 @@ test.describe("Navigation", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand again — the button should still be visible as an icon
|
// Expand again — the button should still be visible as an icon
|
||||||
const expandBtn = page
|
const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last();
|
||||||
.locator("nav button")
|
|
||||||
.filter({ has: page.locator("svg") })
|
|
||||||
.last();
|
|
||||||
await expandBtn.click();
|
await expandBtn.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const boxExpanded = await nav.boundingBox();
|
const boxExpanded = await nav.boundingBox();
|
||||||
@@ -116,10 +113,7 @@ test.describe("Navigation", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// The hamburger button should be visible on mobile
|
// The hamburger button should be visible on mobile
|
||||||
const hamburgerBtn = page
|
const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first();
|
||||||
.locator("button")
|
|
||||||
.filter({ has: page.locator("svg") })
|
|
||||||
.first();
|
|
||||||
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
|
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await hamburgerBtn.click();
|
await hamburgerBtn.click();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -74,9 +74,9 @@ test.describe("Project Detail Page", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// BudgetStatusCard renders budget-related content
|
// BudgetStatusCard renders budget-related content
|
||||||
await expect(page.locator("text=Budget").or(page.locator("text=budget")).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("text=Budget").or(page.locator("text=budget")).first(),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unknown project id shows not-found state", async ({ page }) => {
|
test("unknown project id shows not-found state", async ({ page }) => {
|
||||||
@@ -85,11 +85,7 @@ test.describe("Project Detail Page", () => {
|
|||||||
|
|
||||||
// Server-side notFound() triggers the Next.js 404 page
|
// Server-side notFound() triggers the Next.js 404 page
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("text=404").or(page.locator("text=Not Found")).or(page.locator("text=not found")).first(),
|
||||||
.locator("text=404")
|
|
||||||
.or(page.locator("text=Not Found"))
|
|
||||||
.or(page.locator("text=not found"))
|
|
||||||
.first(),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Projects", () => {
|
test.describe("Projects", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@nexus.dev");
|
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/resources/);
|
await expect(page).toHaveURL(/\/resources/);
|
||||||
@@ -26,16 +26,9 @@ test.describe("Projects", () => {
|
|||||||
// Step 1: Blueprint selection
|
// Step 1: Blueprint selection
|
||||||
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
||||||
// Select the first available blueprint
|
// Select the first available blueprint
|
||||||
const blueprintCard = page
|
const blueprintCard = page.locator("[data-blueprint-id]").first()
|
||||||
.locator("[data-blueprint-id]")
|
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first());
|
||||||
.first()
|
if (await blueprintCard.count() > 0) {
|
||||||
.or(
|
|
||||||
page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /Blueprint|Production/ })
|
|
||||||
.first(),
|
|
||||||
);
|
|
||||||
if ((await blueprintCard.count()) > 0) {
|
|
||||||
await blueprintCard.click();
|
await blueprintCard.click();
|
||||||
} else {
|
} else {
|
||||||
// Click next without blueprint if none shown
|
// Click next without blueprint if none shown
|
||||||
@@ -44,21 +37,16 @@ test.describe("Projects", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Timeline — set project dates
|
// Step 2: Timeline — set project dates
|
||||||
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({
|
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 });
|
||||||
timeout: 5000,
|
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
||||||
});
|
if (await projectNameInput.count() > 0) {
|
||||||
const projectNameInput = page
|
|
||||||
.locator('input[placeholder*="name"]')
|
|
||||||
.or(page.locator('input[name="name"]'))
|
|
||||||
.first();
|
|
||||||
if ((await projectNameInput.count()) > 0) {
|
|
||||||
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
||||||
}
|
}
|
||||||
await page.locator("button", { hasText: "Next" }).click();
|
await page.locator("button", { hasText: "Next" }).click();
|
||||||
|
|
||||||
// Step 3: Staffing demand
|
// Step 3: Staffing demand
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles"))),
|
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles")))
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
await page.locator("button", { hasText: "Next" }).click();
|
await page.locator("button", { hasText: "Next" }).click();
|
||||||
|
|
||||||
@@ -68,13 +56,11 @@ test.describe("Projects", () => {
|
|||||||
|
|
||||||
// Step 5: Review
|
// Step 5: Review
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
const reviewOrFinish = page
|
const reviewOrFinish = page.locator("text=Review").or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
||||||
.locator("text=Review")
|
|
||||||
.or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
|
||||||
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
||||||
// Don't actually submit — just close
|
// Don't actually submit — just close
|
||||||
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
||||||
if ((await cancelBtn.count()) > 0) {
|
if (await cancelBtn.count() > 0) {
|
||||||
await cancelBtn.click();
|
await cancelBtn.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,17 +16,16 @@ test.describe("Chargeability Report", () => {
|
|||||||
|
|
||||||
test("chargeability forecast page loads with heading", async ({ page }) => {
|
test("chargeability forecast page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: "Chargeability Forecast" })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1", { hasText: "Chargeability Forecast" }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter controls are present", async ({ page }) => {
|
test("filter controls are present", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Should have at least one filter (e.g., chapter, period, resource search)
|
// Should have at least one filter (e.g., chapter, period, resource search)
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator('input[type="text"]')
|
||||||
.locator('input[type="text"]')
|
|
||||||
.or(page.locator('input[type="search"]'))
|
.or(page.locator('input[type="search"]'))
|
||||||
.or(page.locator("select"))
|
.or(page.locator("select"))
|
||||||
.first(),
|
.first(),
|
||||||
@@ -65,9 +64,9 @@ test.describe("Report Builder", () => {
|
|||||||
|
|
||||||
test("report builder page loads with heading", async ({ page }) => {
|
test("report builder page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.getByRole("heading", { name: "Report Builder" })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.getByRole("heading", { name: "Report Builder" }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entity selector is present with expected options", async ({ page }) => {
|
test("entity selector is present with expected options", async ({ page }) => {
|
||||||
@@ -79,9 +78,9 @@ test.describe("Report Builder", () => {
|
|||||||
|
|
||||||
test("run report button is visible", async ({ page }) => {
|
test("run report button is visible", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("button", { hasText: /Run|Export|Generate/i }).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("button", { hasText: /Run|Export|Generate/i }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("running a default report produces output or empty state", async ({ page }) => {
|
test("running a default report produces output or empty state", async ({ page }) => {
|
||||||
@@ -91,11 +90,7 @@ test.describe("Report Builder", () => {
|
|||||||
await runBtn.click();
|
await runBtn.click();
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("table").or(page.locator("text=No rows")).or(page.locator("text=0 rows")).first(),
|
||||||
.locator("table")
|
|
||||||
.or(page.locator("text=No rows"))
|
|
||||||
.or(page.locator("text=0 rows"))
|
|
||||||
.first(),
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Resources", () => {
|
test.describe("Resources", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@nexus.dev");
|
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -21,11 +21,10 @@ test.describe("Resources", () => {
|
|||||||
await expect(rows.first()).toBeVisible();
|
await expect(rows.first()).toBeVisible();
|
||||||
|
|
||||||
const firstRowText = (await rows.first().textContent()) ?? "";
|
const firstRowText = (await rows.first().textContent()) ?? "";
|
||||||
const searchTerm =
|
const searchTerm = firstRowText
|
||||||
firstRowText
|
.split(/\s+/)
|
||||||
.split(/\s+/)
|
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
||||||
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
.find((token) => token.length >= 3) ?? "EMP";
|
||||||
.find((token) => token.length >= 3) ?? "EMP";
|
|
||||||
|
|
||||||
const searchInput = page.locator('input[type="search"]');
|
const searchInput = page.locator('input[type="search"]');
|
||||||
await searchInput.fill(searchTerm);
|
await searchInput.fill(searchTerm);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,16 +16,15 @@ test.describe("Scenario Planning", () => {
|
|||||||
|
|
||||||
test("scenarios page loads with heading", async ({ page }) => {
|
test("scenarios page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /Scenario Planning/i })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1", { hasText: /Scenario Planning/i }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows scenarios list or empty state", async ({ page }) => {
|
test("shows scenarios list or empty state", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("table")
|
||||||
.locator("table")
|
|
||||||
.or(page.locator("text=No scenarios"))
|
.or(page.locator("text=No scenarios"))
|
||||||
.or(page.locator("text=Create a project first"))
|
.or(page.locator("text=Create a project first"))
|
||||||
.or(page.locator("[data-testid]"))
|
.or(page.locator("[data-testid]"))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test("signin page renders credential inputs and submit button", async ({ page })
|
|||||||
|
|
||||||
test("admin login succeeds and redirects away from signin", async ({ page }) => {
|
test("admin login succeeds and redirects away from signin", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||||
@@ -37,7 +37,7 @@ test("admin login succeeds and redirects away from signin", async ({ page }) =>
|
|||||||
|
|
||||||
test("authenticated user sees app shell nav", async ({ page }) => {
|
test("authenticated user sees app shell nav", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Staffing", () => {
|
test.describe("Staffing", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -12,9 +12,7 @@ test.describe("Staffing", () => {
|
|||||||
|
|
||||||
test("staffing page loads with search form", async ({ page }) => {
|
test("staffing page loads with search form", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({
|
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ timeout: 10000 });
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
// Search form should have skill input, date fields, and a search button
|
// Search form should have skill input, date fields, and a search button
|
||||||
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
@@ -22,9 +20,9 @@ test.describe("Staffing", () => {
|
|||||||
test("search form has default skill tags", async ({ page }) => {
|
test("search form has default skill tags", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// The StaffingPanel pre-populates with TypeScript and React skill tags
|
// The StaffingPanel pre-populates with TypeScript and React skill tags
|
||||||
await expect(page.locator("text=TypeScript").or(page.locator("text=React"))).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("text=TypeScript").or(page.locator("text=React")),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitting search returns suggestions or empty state", async ({ page }) => {
|
test("submitting search returns suggestions or empty state", async ({ page }) => {
|
||||||
@@ -36,9 +34,7 @@ test.describe("Staffing", () => {
|
|||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
// After search, should show either suggestion cards or a "no suggestions" message
|
// After search, should show either suggestion cards or a "no suggestions" message
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("text=/Score|Availability|No suggestions|No matching/i").first()
|
||||||
.locator("text=/Score|Availability|No suggestions|No matching/i")
|
|
||||||
.first()
|
|
||||||
.or(page.locator("[data-suggestion]").first())
|
.or(page.locator("[data-suggestion]").first())
|
||||||
.or(page.locator("table").first()),
|
.or(page.locator("table").first()),
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -393,9 +393,9 @@ try {
|
|||||||
await cleanupStaleE2eArtifacts();
|
await cleanupStaleE2eArtifacts();
|
||||||
await ensureE2eDatabaseContainer();
|
await ensureE2eDatabaseContainer();
|
||||||
}
|
}
|
||||||
await run("pnpm", ["--filter", "@nexus/db", "db:push"], workspaceRoot);
|
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
|
||||||
await run("pnpm", ["--filter", "@nexus/db", "db:seed"], workspaceRoot);
|
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
|
||||||
await run("pnpm", ["--filter", "@nexus/db", "db:seed:holidays"], workspaceRoot);
|
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
|
||||||
rmSync(webDistDirPath, { recursive: true, force: true });
|
rmSync(webDistDirPath, { recursive: true, force: true });
|
||||||
|
|
||||||
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
||||||
|
|||||||
+158
-285
@@ -133,7 +133,7 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
|
|||||||
data: {
|
data: {
|
||||||
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
||||||
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
||||||
email: ${JSON.stringify(`e2e.timeline.${suffix}@nexus.dev`)},
|
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)},
|
||||||
chapter: "E2E",
|
chapter: "E2E",
|
||||||
lcrCents: 5000,
|
lcrCents: 5000,
|
||||||
ucrCents: 9000,
|
ucrCents: 9000,
|
||||||
@@ -208,7 +208,7 @@ function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
|
|||||||
data: {
|
data: {
|
||||||
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
|
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
|
||||||
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
|
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
|
||||||
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@nexus.dev`)},
|
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)},
|
||||||
chapter: "E2E",
|
chapter: "E2E",
|
||||||
lcrCents: 5000,
|
lcrCents: 5000,
|
||||||
ucrCents: 9000,
|
ucrCents: 9000,
|
||||||
@@ -341,9 +341,7 @@ function listScenarioAssignments(projectId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listScenarioDemands(projectId: string) {
|
function listScenarioDemands(projectId: string) {
|
||||||
return runDbJson<
|
return runDbJson<Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>>(`
|
||||||
Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>
|
|
||||||
>(`
|
|
||||||
const demands = await prisma.demandRequirement.findMany({
|
const demands = await prisma.demandRequirement.findMany({
|
||||||
where: { projectId: ${JSON.stringify(projectId)} },
|
where: { projectId: ${JSON.stringify(projectId)} },
|
||||||
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
||||||
@@ -450,7 +448,10 @@ async function openAllocationContextMenuAtOffset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openContextMenuAtCenter(page: Page, locator: ReturnType<Page["locator"]>) {
|
async function openContextMenuAtCenter(
|
||||||
|
page: Page,
|
||||||
|
locator: ReturnType<Page["locator"]>,
|
||||||
|
) {
|
||||||
const target = await resolveAllocationContextMenuTarget(locator);
|
const target = await resolveAllocationContextMenuTarget(locator);
|
||||||
const box = await readBoundingBox(target);
|
const box = await readBoundingBox(target);
|
||||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||||
@@ -510,7 +511,9 @@ async function listRenderedAllocationSegments(
|
|||||||
row: ReturnType<Page["locator"]>,
|
row: ReturnType<Page["locator"]>,
|
||||||
allocationId?: string,
|
allocationId?: string,
|
||||||
) {
|
) {
|
||||||
const selector = allocationId ? `[data-allocation-id="${allocationId}"]` : "[data-allocation-id]";
|
const selector = allocationId
|
||||||
|
? `[data-allocation-id="${allocationId}"]`
|
||||||
|
: "[data-allocation-id]";
|
||||||
return row.locator(selector).evaluateAll((elements) =>
|
return row.locator(selector).evaluateAll((elements) =>
|
||||||
elements.map((element) => {
|
elements.map((element) => {
|
||||||
const htmlElement = element as HTMLElement;
|
const htmlElement = element as HTMLElement;
|
||||||
@@ -533,13 +536,17 @@ function escapeRegex(value: string) {
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findVisibleTimelineEntryId(page: Page, selector: string, minimumWidth = 24) {
|
async function findVisibleTimelineEntryId(
|
||||||
|
page: Page,
|
||||||
|
selector: string,
|
||||||
|
minimumWidth = 24,
|
||||||
|
) {
|
||||||
return page.locator(selector).evaluateAll((elements, minimum) => {
|
return page.locator(selector).evaluateAll((elements, minimum) => {
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!(element instanceof HTMLElement)) continue;
|
if (!(element instanceof HTMLElement)) continue;
|
||||||
@@ -593,9 +600,9 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
);
|
);
|
||||||
const stickyHeaderBottom = scrollContainer
|
const stickyHeaderBottom = scrollContainer
|
||||||
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
||||||
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
||||||
const candidates: Array<{
|
const candidates: Array<{
|
||||||
@@ -604,11 +611,8 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
segmentEnd: string | null;
|
segmentEnd: string | null;
|
||||||
score: number;
|
score: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
let fallback: {
|
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
|
||||||
allocationId: string;
|
null;
|
||||||
segmentStart: string | null;
|
|
||||||
segmentEnd: string | null;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!(element instanceof HTMLElement)) continue;
|
if (!(element instanceof HTMLElement)) continue;
|
||||||
@@ -825,20 +829,13 @@ async function switchToProjectView(page: Page, readySelector?: string) {
|
|||||||
await expect(page.locator(readySelector).first()).toBeVisible();
|
await expect(page.locator(readySelector).first()).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(async () => {
|
||||||
async () => {
|
const projectRows = await page.getByTestId("timeline-project-resource-row-canvas").count();
|
||||||
const projectRows = await page
|
const projectBars = await page.locator("[data-timeline-entry-type='project-bar']").count();
|
||||||
.getByTestId("timeline-project-resource-row-canvas")
|
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
||||||
.count();
|
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
||||||
const projectBars = await page
|
return projectRows + projectBars + demandBars + emptyStates;
|
||||||
.locator("[data-timeline-entry-type='project-bar']")
|
}, { timeout: 10_000 })
|
||||||
.count();
|
|
||||||
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
|
||||||
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
|
||||||
return projectRows + projectBars + demandBars + emptyStates;
|
|
||||||
},
|
|
||||||
{ timeout: 10_000 },
|
|
||||||
)
|
|
||||||
.not.toBe(0);
|
.not.toBe(0);
|
||||||
}
|
}
|
||||||
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
|
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
|
||||||
@@ -909,21 +906,22 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({
|
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const scenario = createTimelineSegmentScenario(suffix);
|
const scenario = createTimelineSegmentScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, {
|
await page.goto(
|
||||||
waitUntil: "domcontentloaded",
|
`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`,
|
||||||
});
|
{ waitUntil: "domcontentloaded" },
|
||||||
|
);
|
||||||
|
|
||||||
const projectButton = page.getByRole("button", { name: "Project view" });
|
const projectButton = page.getByRole("button", { name: "Project view" });
|
||||||
const resourceButton = page.getByRole("button", { name: "Resource view" });
|
const resourceButton = page.getByRole("button", { name: "Resource view" });
|
||||||
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
const resourceRowSelector =
|
||||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||||
|
const projectRowSelector =
|
||||||
|
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
|
|
||||||
await expect(projectButton).toBeDisabled();
|
await expect(projectButton).toBeDisabled();
|
||||||
await expect(resourceButton).toBeDisabled();
|
await expect(resourceButton).toBeDisabled();
|
||||||
@@ -953,9 +951,9 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('nav a >> text="Timeline"').first().click();
|
await page.locator('nav a >> text="Timeline"').first().click();
|
||||||
await expect(page).toHaveURL(/\/timeline/);
|
await expect(page).toHaveURL(/\/timeline/);
|
||||||
@@ -1048,10 +1046,7 @@ test.describe("Timeline", () => {
|
|||||||
if (!projectAllocationBox) {
|
if (!projectAllocationBox) {
|
||||||
throw new Error("Expected a project allocation block to be available");
|
throw new Error("Expected a project allocation block to be available");
|
||||||
}
|
}
|
||||||
await page.mouse.move(
|
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||||||
projectAllocationBox.x + projectAllocationBox.width / 2,
|
|
||||||
projectHoverBox.y + 20,
|
|
||||||
);
|
|
||||||
await expect(heatmapTooltip).toBeVisible();
|
await expect(heatmapTooltip).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -1076,9 +1071,7 @@ test.describe("Timeline", () => {
|
|||||||
.first();
|
.first();
|
||||||
await allocation.click({ button: "right" });
|
await allocation.click({ button: "right" });
|
||||||
|
|
||||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
||||||
timeout: 2_000,
|
|
||||||
});
|
|
||||||
const popover = page.getByTestId("timeline-allocation-popover");
|
const popover = page.getByTestId("timeline-allocation-popover");
|
||||||
await expect(popover).toBeVisible();
|
await expect(popover).toBeVisible();
|
||||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
@@ -1110,16 +1103,12 @@ test.describe("Timeline", () => {
|
|||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = page
|
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||||||
.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]')
|
|
||||||
.first();
|
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const holidayBlock = row
|
const holidayBlock = row.locator(
|
||||||
.locator(
|
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
).first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(holidayBlock).toBeVisible();
|
await expect(holidayBlock).toBeVisible();
|
||||||
|
|
||||||
const rowBox = await row.boundingBox();
|
const rowBox = await row.boundingBox();
|
||||||
@@ -1140,9 +1129,7 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
const holidayTooltip = page
|
const holidayTooltip = page
|
||||||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||||
.or(
|
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||||||
page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }),
|
|
||||||
)
|
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
await expect(holidayTooltip).toBeVisible();
|
await expect(holidayTooltip).toBeVisible();
|
||||||
@@ -1291,7 +1278,9 @@ test.describe("Timeline", () => {
|
|||||||
expect(result.maxGap).toBeLessThan(24);
|
expect(result.maxGap).toBeLessThan(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allocation resize shows a live preview before mouseup", async ({ page }) => {
|
test("allocation resize shows a live preview before mouseup", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
@@ -1369,7 +1358,9 @@ test.describe("Timeline", () => {
|
|||||||
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allocation start resize shows a live preview before mouseup", async ({ page }) => {
|
test("allocation start resize shows a live preview before mouseup", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
@@ -1403,17 +1394,18 @@ test.describe("Timeline", () => {
|
|||||||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
const resourceRowSelector =
|
||||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||||
const projectAllocationSelector = `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
const projectRowSelector =
|
||||||
|
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
|
const projectAllocationSelector =
|
||||||
|
`${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
||||||
|
|
||||||
await expect(page.locator(resourceRowSelector)).toBeVisible();
|
await expect(page.locator(resourceRowSelector)).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator(
|
||||||
.locator(
|
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
||||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
).first(),
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await switchToProjectView(page, projectRowSelector);
|
await switchToProjectView(page, projectRowSelector);
|
||||||
@@ -1435,22 +1427,19 @@ test.describe("Timeline", () => {
|
|||||||
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
|
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
|
||||||
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => {
|
||||||
() => {
|
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||||
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
if (rightResizeAssignments.length !== 1) {
|
||||||
if (rightResizeAssignments.length !== 1) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [assignment] = rightResizeAssignments;
|
const [assignment] = rightResizeAssignments;
|
||||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment.endDate;
|
return assignment.endDate;
|
||||||
},
|
}, { timeout: 15_000 })
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("2026-04-17");
|
.not.toBe("2026-04-17");
|
||||||
expect(rightResizeAssignments).toHaveLength(1);
|
expect(rightResizeAssignments).toHaveLength(1);
|
||||||
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
@@ -1462,22 +1451,19 @@ test.describe("Timeline", () => {
|
|||||||
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
|
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
|
||||||
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => {
|
||||||
() => {
|
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||||
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
if (leftResizeAssignments.length !== 1) {
|
||||||
if (leftResizeAssignments.length !== 1) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [assignment] = leftResizeAssignments;
|
const [assignment] = leftResizeAssignments;
|
||||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment.startDate;
|
return assignment.startDate;
|
||||||
},
|
}, { timeout: 15_000 })
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("2026-04-06");
|
.not.toBe("2026-04-06");
|
||||||
expect(leftResizeAssignments).toHaveLength(1);
|
expect(leftResizeAssignments).toHaveLength(1);
|
||||||
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
@@ -1493,12 +1479,15 @@ test.describe("Timeline", () => {
|
|||||||
const scenario = createTimelineDemandScenario(suffix);
|
const scenario = createTimelineDemandScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
await page.goto(
|
||||||
waitUntil: "domcontentloaded",
|
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
||||||
});
|
{ waitUntil: "domcontentloaded" },
|
||||||
|
);
|
||||||
await ensureOpenDemandVisibilityEnabled(page);
|
await ensureOpenDemandVisibilityEnabled(page);
|
||||||
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
const demandRowSelector =
|
||||||
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||||
|
const demandSelector =
|
||||||
|
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||||
|
|
||||||
await switchToProjectView(page, demandRowSelector);
|
await switchToProjectView(page, demandRowSelector);
|
||||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||||
@@ -1516,22 +1505,19 @@ test.describe("Timeline", () => {
|
|||||||
status: string;
|
status: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => {
|
||||||
() => {
|
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
||||||
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
if (rightResizeDemands.length !== 1) {
|
||||||
if (rightResizeDemands.length !== 1) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [demand] = rightResizeDemands;
|
const [demand] = rightResizeDemands;
|
||||||
if (!demand || demand.id !== scenario.demandId) {
|
if (!demand || demand.id !== scenario.demandId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return demand.endDate;
|
return demand.endDate;
|
||||||
},
|
}, { timeout: 15_000 })
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("2026-04-16");
|
.not.toBe("2026-04-16");
|
||||||
expect(rightResizeDemands).toHaveLength(1);
|
expect(rightResizeDemands).toHaveLength(1);
|
||||||
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
@@ -1552,22 +1538,19 @@ test.describe("Timeline", () => {
|
|||||||
status: string;
|
status: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => {
|
||||||
() => {
|
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
||||||
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
if (leftResizeDemands.length !== 1) {
|
||||||
if (leftResizeDemands.length !== 1) {
|
return null;
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [demand] = leftResizeDemands;
|
const [demand] = leftResizeDemands;
|
||||||
if (!demand || demand.id !== scenario.demandId) {
|
if (!demand || demand.id !== scenario.demandId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return demand.startDate;
|
return demand.startDate;
|
||||||
},
|
}, { timeout: 15_000 })
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("2026-04-07");
|
.not.toBe("2026-04-07");
|
||||||
expect(leftResizeDemands).toHaveLength(1);
|
expect(leftResizeDemands).toHaveLength(1);
|
||||||
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
@@ -1647,11 +1630,7 @@ test.describe("Timeline", () => {
|
|||||||
);
|
);
|
||||||
await expect(resizedSegment).toBeVisible();
|
await expect(resizedSegment).toBeVisible();
|
||||||
|
|
||||||
await dragLocatorBy(
|
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
|
||||||
page,
|
|
||||||
resizedSegment.locator('[data-allocation-interaction="body"]'),
|
|
||||||
-dayWidth,
|
|
||||||
);
|
|
||||||
await releaseMouse(page);
|
await releaseMouse(page);
|
||||||
|
|
||||||
await waitForScenarioAssignments(scenario.projectId, [
|
await waitForScenarioAssignments(scenario.projectId, [
|
||||||
@@ -1695,21 +1674,9 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row
|
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||||
.locator(
|
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const rightSplit = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const nextWeekSegment = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(nextWeekSegment).toBeVisible();
|
await expect(nextWeekSegment).toBeVisible();
|
||||||
@@ -1737,42 +1704,22 @@ test.describe("Timeline", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -1822,21 +1769,9 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const leftSplit = row
|
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||||
.locator(
|
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first();
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const fridayBridge = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const mondaySegment = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(fridayBridge).toBeVisible();
|
await expect(fridayBridge).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1862,25 +1797,13 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -1927,21 +1850,9 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row
|
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||||
.locator(
|
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const rightSplit = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const mondaySegment = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1959,16 +1870,8 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resizedRightSplit = row
|
const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||||
.locator(
|
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth);
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await dragLocatorBy(
|
|
||||||
page,
|
|
||||||
resizedRightSplit.locator('[data-allocation-handle="end"]'),
|
|
||||||
-dayWidth,
|
|
||||||
);
|
|
||||||
await releaseMouse(page);
|
await releaseMouse(page);
|
||||||
|
|
||||||
await waitForScenarioAssignments(scenario.projectId, [
|
await waitForScenarioAssignments(scenario.projectId, [
|
||||||
@@ -1980,11 +1883,9 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const mondaySegmentAfterReload = row
|
const mondaySegmentAfterReload = row.locator(
|
||||||
.locator(
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
).first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(mondaySegmentAfterReload).toBeVisible();
|
await expect(mondaySegmentAfterReload).toBeVisible();
|
||||||
|
|
||||||
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||||||
@@ -2050,21 +1951,9 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row
|
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||||
.locator(
|
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const rightSplit = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
const mondaySegment = row
|
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -2079,11 +1968,7 @@ test.describe("Timeline", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -2091,25 +1976,13 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row
|
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||||
.locator(
|
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
|
||||||
)
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -2156,14 +2029,13 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mondaySegment = resourceRow
|
const mondaySegment = resourceRow.locator(
|
||||||
.locator(
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
).first();
|
||||||
)
|
|
||||||
.first();
|
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
|
|
||||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
const projectRowSelector =
|
||||||
|
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
await switchToProjectView(page, projectRowSelector);
|
await switchToProjectView(page, projectRowSelector);
|
||||||
|
|
||||||
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
||||||
@@ -2200,9 +2072,7 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||||
|
|
||||||
const projectAllocationAfterReload = page
|
const projectAllocationAfterReload = page
|
||||||
.locator(
|
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
|
||||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
|
|
||||||
)
|
|
||||||
.first();
|
.first();
|
||||||
await expect(projectAllocationAfterReload).toBeVisible();
|
await expect(projectAllocationAfterReload).toBeVisible();
|
||||||
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
||||||
@@ -2223,12 +2093,15 @@ test.describe("Timeline", () => {
|
|||||||
const scenario = createTimelineDemandScenario(suffix);
|
const scenario = createTimelineDemandScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
await page.goto(
|
||||||
waitUntil: "domcontentloaded",
|
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
||||||
});
|
{ waitUntil: "domcontentloaded" },
|
||||||
|
);
|
||||||
await ensureOpenDemandVisibilityEnabled(page);
|
await ensureOpenDemandVisibilityEnabled(page);
|
||||||
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
const demandRowSelector =
|
||||||
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||||
|
const demandSelector =
|
||||||
|
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||||
|
|
||||||
await switchToProjectView(page, demandRowSelector);
|
await switchToProjectView(page, demandRowSelector);
|
||||||
await expect(page.locator(demandSelector)).toBeVisible();
|
await expect(page.locator(demandSelector)).toBeVisible();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -27,9 +27,9 @@ test.describe("Vacations", () => {
|
|||||||
|
|
||||||
test("request vacation button is visible", async ({ page }) => {
|
test("request vacation button is visible", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("button", { hasText: /Request Vacation/i })).toBeVisible({
|
await expect(
|
||||||
timeout: 10000,
|
page.locator("button", { hasText: /Request Vacation/i }),
|
||||||
});
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request vacation is blocked without linked resource", async ({ page }) => {
|
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||||
@@ -37,9 +37,7 @@ test.describe("Vacations", () => {
|
|||||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||||
await expect(reqBtn).toBeDisabled();
|
await expect(reqBtn).toBeDisabled();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(
|
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
||||||
"Your account is not linked to a resource. Please contact an administrator.",
|
|
||||||
),
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -59,18 +57,11 @@ test.describe("Vacations", () => {
|
|||||||
|
|
||||||
test("team calendar tab renders", async ({ page }) => {
|
test("team calendar tab renders", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page
|
await page.locator("button", { hasText: "Team Calendar" }).or(page.locator("text=Team Calendar")).first().click();
|
||||||
.locator("button", { hasText: "Team Calendar" })
|
|
||||||
.or(page.locator("text=Team Calendar"))
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
// Calendar view should appear
|
// Calendar view should appear
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator("table").or(page.locator("[data-calendar]")).or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
||||||
.locator("table")
|
|
||||||
.or(page.locator("[data-calendar]"))
|
|
||||||
.or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,15 +75,11 @@ test.describe("Vacations", () => {
|
|||||||
await expect(filters.nth(2)).toHaveValue("");
|
await expect(filters.nth(2)).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("vacation request preview excludes regional public holidays from deducted days", async ({
|
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.getByRole("button", { name: /request vacation/i }).click();
|
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
||||||
page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||||
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||||
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||||
@@ -102,13 +89,9 @@ test.describe("Vacations", () => {
|
|||||||
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||||
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||||
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText(
|
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
||||||
"2026-01-06",
|
|
||||||
);
|
|
||||||
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText(
|
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
||||||
"Holiday Calendar",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import nextjsConfig from "@nexus/eslint-config/nextjs";
|
import nextjsConfig from "@capakraken/eslint-config/nextjs";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ const nextConfig: NextConfig = {
|
|||||||
"recharts",
|
"recharts",
|
||||||
"date-fns",
|
"date-fns",
|
||||||
"framer-motion",
|
"framer-motion",
|
||||||
"@nexus/shared",
|
"@capakraken/shared",
|
||||||
"@react-pdf/renderer",
|
"@react-pdf/renderer",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@nexus/api",
|
"@capakraken/api",
|
||||||
"@nexus/db",
|
"@capakraken/db",
|
||||||
"@nexus/engine",
|
"@capakraken/engine",
|
||||||
"@nexus/shared",
|
"@capakraken/shared",
|
||||||
"@nexus/staffing",
|
"@capakraken/staffing",
|
||||||
],
|
],
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@nexus/web",
|
"name": "@capakraken/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
|
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nexus/api": "workspace:*",
|
"@capakraken/api": "workspace:*",
|
||||||
"@nexus/application": "workspace:*",
|
"@capakraken/application": "workspace:*",
|
||||||
"@nexus/db": "workspace:*",
|
"@capakraken/db": "workspace:*",
|
||||||
"@nexus/engine": "workspace:*",
|
"@capakraken/engine": "workspace:*",
|
||||||
"@nexus/shared": "workspace:*",
|
"@capakraken/shared": "workspace:*",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"dompurify": "^3.4.0",
|
"dompurify": "^3.4.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"next": "^15.5.16",
|
"next": "^15.5.15",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "^16.2.3",
|
"@next/bundle-analyzer": "^16.2.3",
|
||||||
"@axe-core/playwright": "^4.11.1",
|
"@axe-core/playwright": "^4.11.1",
|
||||||
"@nexus/eslint-config": "workspace:*",
|
"@capakraken/eslint-config": "workspace:*",
|
||||||
"@nexus/tsconfig": "workspace:*",
|
"@capakraken/tsconfig": "workspace:*",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* dev server at localhost:3100 and exercises real dev-DB data.
|
* dev server at localhost:3100 and exercises real dev-DB data.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm --filter @nexus/web exec playwright test --config playwright.dev.config.ts
|
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts
|
||||||
*
|
*
|
||||||
* Prerequisites:
|
* Prerequisites:
|
||||||
* - Dev server running: pnpm run dev (or docker compose up)
|
* - Dev server running: pnpm run dev (or docker compose up)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Nexus — Resource & Capacity Planning",
|
"name": "CapaKraken — Resource & Capacity Planning",
|
||||||
"short_name": "Nexus",
|
"short_name": "CapaKraken",
|
||||||
"description": "Resource planning and project staffing for 3D production",
|
"description": "Resource planning and project staffing for 3D production",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
const CACHE_NAME = "nexus-v2";
|
const CACHE_NAME = "capakraken-v2";
|
||||||
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
||||||
|
|
||||||
// Offline fallback page (simple inline HTML)
|
// Offline fallback page (simple inline HTML)
|
||||||
@@ -9,7 +9,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Nexus - Offline</title>
|
<title>CapaKraken - Offline</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -31,7 +31,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>You are offline</h1>
|
<h1>You are offline</h1>
|
||||||
<p>Nexus requires an internet connection. Please check your network and try again.</p>
|
<p>CapaKraken requires an internet connection. Please check your network and try again.</p>
|
||||||
<button onclick="location.reload()">Retry</button>
|
<button onclick="location.reload()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
|
|||||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||||
|
|
||||||
export const metadata = { title: "Vacation Management — Nexus" };
|
export const metadata = { title: "Vacation Management — CapaKraken" };
|
||||||
|
|
||||||
export default function AdminVacationsPage() {
|
export default function AdminVacationsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -10,19 +10,15 @@ export default function AdminVacationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
|
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
|
||||||
Fallback-Importe.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
|
||||||
Holiday Calendars
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung,
|
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||||
Timeline-Overlay und Assistant-Abfragen verwendet.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<HolidayCalendarEditor />
|
<HolidayCalendarEditor />
|
||||||
@@ -30,12 +26,9 @@ export default function AdminVacationsPage() {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
|
||||||
Legacy Batch Import
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die
|
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||||
Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PublicHolidayBatch />
|
<PublicHolidayBatch />
|
||||||
@@ -43,12 +36,9 @@ export default function AdminVacationsPage() {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
|
||||||
Entitlements
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
|
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
|
||||||
aufgeloest wurden.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EntitlementManager />
|
<EntitlementManager />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
|
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
@@ -122,8 +122,7 @@ function EstimateDetailPanel({
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
||||||
Estimate detail{" "}
|
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
||||||
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{estimate.name}
|
{estimate.name}
|
||||||
@@ -207,8 +206,7 @@ function EstimateDetailPanel({
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Scope items{" "}
|
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
||||||
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,8 +239,7 @@ function EstimateDetailPanel({
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Demand lines{" "}
|
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
||||||
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,19 +345,13 @@ function EstimateCard({
|
|||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
|
||||||
Opportunity{" "}
|
|
||||||
<InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." />
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||||
{estimate.opportunityId ?? "Not set"}
|
{estimate.opportunityId ?? "Not set"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
|
||||||
Updated{" "}
|
|
||||||
<InfoTooltip content="When this estimate or any of its versions was last modified." />
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||||
{formatDateLong(estimate.updatedAt)}
|
{formatDateLong(estimate.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
@@ -475,7 +466,7 @@ export function EstimatesClient() {
|
|||||||
No estimates yet
|
No estimates yet
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||||
Start with the wizard to create a connected estimate from Nexus data.
|
Start with the wizard to create a connected estimate from CapaKraken data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Nexus — Mobile Summary",
|
title: "CapaKraken — Mobile Summary",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MobilePage() {
|
export default function MobilePage() {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
|||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||||
import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
|
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
|
||||||
import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
|
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|||||||
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Resource, SkillEntry } from "@nexus/shared";
|
import type { Resource, SkillEntry } from "@capakraken/shared";
|
||||||
import { RESOURCE_COLUMNS } from "@nexus/shared";
|
import { RESOURCE_COLUMNS } from "@capakraken/shared";
|
||||||
import { BlueprintTarget, ResourceType } from "@nexus/shared";
|
import { BlueprintTarget, ResourceType } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { formatMoney } from "~/lib/format.js";
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
||||||
@@ -945,7 +945,7 @@ export function ResourcesClient() {
|
|||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onSort={toggle}
|
onSort={toggle}
|
||||||
tooltip="Unique employee identifier used across all Nexus records."
|
tooltip="Unique employee identifier used across all CapaKraken records."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "displayName":
|
case "displayName":
|
||||||
|
|||||||
@@ -2,22 +2,24 @@ import type { Metadata } from "next";
|
|||||||
import { createCaller } from "~/server/trpc.js";
|
import { createCaller } from "~/server/trpc.js";
|
||||||
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata(
|
||||||
params,
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
}: {
|
): Promise<Metadata> {
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}): Promise<Metadata> {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const trpc = await createCaller();
|
const trpc = await createCaller();
|
||||||
const resource = await trpc.resource.getById({ id });
|
const resource = await trpc.resource.getById({ id });
|
||||||
return { title: `${resource.displayName} — Resources | Nexus` };
|
return { title: `${resource.displayName} — Resources | CapaKraken` };
|
||||||
} catch {
|
} catch {
|
||||||
return { title: "Resource — Nexus" };
|
return { title: "Resource — CapaKraken" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ResourceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <ResourceDetail resourceId={id} />;
|
return <ResourceDetail resourceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Resource } from "@nexus/shared";
|
import type { Resource } from "@capakraken/shared";
|
||||||
import { ResourceType } from "@nexus/shared";
|
import { ResourceType } from "@capakraken/shared";
|
||||||
|
|
||||||
export type ModalState =
|
export type ModalState =
|
||||||
| { type: "closed" }
|
| { type: "closed" }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
||||||
|
|
||||||
export const metadata = { title: "My Vacations — Nexus" };
|
export const metadata = { title: "My Vacations — CapaKraken" };
|
||||||
|
|
||||||
export default function MyVacationsPage() {
|
export default function MyVacationsPage() {
|
||||||
return <MyVacationsClient />;
|
return <MyVacationsClient />;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
|
|
||||||
/** Window over which auth events are analysed. */
|
/** Window over which auth events are analysed. */
|
||||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
|
|||||||
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
|
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
|
||||||
const userFindManyMock = vi.hoisted(() => vi.fn());
|
const userFindManyMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@nexus/db", () => ({
|
vi.mock("@capakraken/db", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
auditLog: { findMany: auditLogFindManyMock },
|
auditLog: { findMany: auditLogFindManyMock },
|
||||||
user: { findMany: userFindManyMock },
|
user: { findMany: userFindManyMock },
|
||||||
@@ -27,11 +27,11 @@ vi.mock("@nexus/db", () => ({
|
|||||||
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
|
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
|
||||||
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
vi.mock("@nexus/api", () => ({
|
vi.mock("@capakraken/api", () => ({
|
||||||
createNotificationsForUsers: createNotificationsMock,
|
createNotificationsForUsers: createNotificationsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@nexus/api/lib/logger", () => ({
|
vi.mock("@capakraken/api/lib/logger", () => ({
|
||||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { createNotificationsForUsers } from "@nexus/api";
|
import { createNotificationsForUsers } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
import { detectAuthAnomalies } from "./detect.js";
|
import { detectAuthAnomalies } from "./detect.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { checkChargeabilityAlerts } from "@nexus/api";
|
import { checkChargeabilityAlerts } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { checkPendingEstimateReminders } from "@nexus/api";
|
import { checkPendingEstimateReminders } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { createNotificationsForUsers } from "@nexus/api";
|
import { createNotificationsForUsers } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { autoImportPublicHolidays } from "@nexus/api";
|
import { autoImportPublicHolidays } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
|
|||||||
skippedExisting: result.skippedExisting,
|
skippedExisting: result.skippedExisting,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
|
||||||
{ error, route: "/api/cron/public-holidays", year },
|
return NextResponse.json(
|
||||||
"Public holiday import cron failed",
|
{ ok: false, error: "Internal error" },
|
||||||
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { createNotificationsForUsers } from "@nexus/api";
|
import { createNotificationsForUsers } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { sendWeeklyDigest } from "@nexus/api";
|
import { sendWeeklyDigest } from "@capakraken/api";
|
||||||
import { logger } from "@nexus/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -30,14 +30,8 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
resolve(data.toString().includes("PONG") ? "ok" : "error");
|
resolve(data.toString().includes("PONG") ? "ok" : "error");
|
||||||
});
|
});
|
||||||
socket.on("timeout", () => {
|
socket.on("timeout", () => { socket.destroy(); resolve("error"); });
|
||||||
socket.destroy();
|
socket.on("error", () => { socket.destroy(); resolve("error"); });
|
||||||
resolve("error");
|
|
||||||
});
|
|
||||||
socket.on("error", () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve("error");
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
resolve("error");
|
resolve("error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@nexus/api/sse", () => ({
|
vi.mock("@capakraken/api/sse", () => ({
|
||||||
eventBus: { subscriberCount: 0 },
|
eventBus: { subscriberCount: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
|
|||||||
const response = await GET(request);
|
const response = await GET(request);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
const body = (await response.json()) as { timestamp: string; uptime: unknown; memory: unknown };
|
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown };
|
||||||
expect(typeof body.timestamp).toBe("string");
|
expect(typeof body.timestamp).toBe("string");
|
||||||
expect(body.uptime).toBeDefined();
|
expect(body.uptime).toBeDefined();
|
||||||
expect(body.memory).toBeDefined();
|
expect(body.memory).toBeDefined();
|
||||||
@@ -81,11 +81,7 @@ describe("GET /api/perf — security hardening", () => {
|
|||||||
const response = await GET(request);
|
const response = await GET(request);
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
|
|
||||||
const body = (await response.json()) as {
|
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
|
||||||
error?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
memory?: unknown;
|
|
||||||
};
|
|
||||||
expect(body.timestamp).toBeUndefined();
|
expect(body.timestamp).toBeUndefined();
|
||||||
expect(body.memory).toBeUndefined();
|
expect(body.memory).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { eventBus } from "@nexus/api/sse";
|
import { eventBus } from "@capakraken/api/sse";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight Redis PING check using a raw TCP socket.
|
* Lightweight Redis PING check using a raw TCP socket.
|
||||||
* Avoids importing ioredis (which is only a dependency of @nexus/api).
|
* Avoids importing ioredis (which is only a dependency of @capakraken/api).
|
||||||
*/
|
*/
|
||||||
async function checkRedis(): Promise<"ok" | "error"> {
|
async function checkRedis(): Promise<"ok" | "error"> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -58,7 +58,10 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
|
const [postgres, redis] = await Promise.all([
|
||||||
|
checkPostgres(),
|
||||||
|
checkRedis(),
|
||||||
|
]);
|
||||||
|
|
||||||
const allHealthy = postgres === "ok" && redis === "ok";
|
const allHealthy = postgres === "ok" && redis === "ok";
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
|
|||||||
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
|
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
|
||||||
|
|
||||||
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
|
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
|
||||||
vi.mock("@nexus/db", () => ({
|
vi.mock("@capakraken/db", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
@@ -21,11 +21,11 @@ vi.mock("@nexus/db", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@nexus/application", () => ({
|
vi.mock("@capakraken/application", () => ({
|
||||||
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
|
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@nexus/api", () => ({
|
vi.mock("@capakraken/api", () => ({
|
||||||
anonymizeResource: vi.fn((r: unknown) => r),
|
anonymizeResource: vi.fn((r: unknown) => r),
|
||||||
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { renderToBuffer } from "@react-pdf/renderer";
|
|||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildSplitAllocationReadModel } from "@nexus/application";
|
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
|
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import type { AllocationLike } from "@nexus/shared";
|
import type { AllocationLike } from "@capakraken/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
||||||
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { loadRoleDefaults } from "@nexus/api";
|
import { loadRoleDefaults } from "@capakraken/api";
|
||||||
import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
|
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
||||||
import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
|
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import type { SystemRole } from "@nexus/shared";
|
import type { SystemRole } from "@capakraken/shared";
|
||||||
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
|
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
|
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api";
|
||||||
import { appRouter } from "@nexus/api/router";
|
import { appRouter } from "@capakraken/api/router";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default function SignInPage() {
|
|||||||
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
||||||
Nexus Control Center
|
CapaKraken Control Center
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
||||||
Resource planning that stays readable under pressure.
|
Resource planning that stays readable under pressure.
|
||||||
@@ -137,7 +137,7 @@ export default function SignInPage() {
|
|||||||
Welcome Back
|
Welcome Back
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
|
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{mfaRequired
|
{mfaRequired
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, use } from "react";
|
import { useState, use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
@@ -91,7 +91,7 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
You have been invited as <strong>{invite.role}</strong> to Nexus. Set a password to
|
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
|
||||||
activate your account (<span className="font-medium">{invite.email}</span>).
|
activate your account (<span className="font-medium">{invite.email}</span>).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-51
@@ -19,8 +19,8 @@ const displayFont = Manrope({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
|
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"),
|
||||||
title: "Nexus — Resource & Capacity Planning",
|
title: "CapaKraken — Resource & Capacity Planning",
|
||||||
description: "Interactive resource planning and project staffing tool",
|
description: "Interactive resource planning and project staffing tool",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
icons: {
|
icons: {
|
||||||
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
|
|||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "Nexus",
|
title: "CapaKraken",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Nexus — Resource & Capacity Planning",
|
title: "CapaKraken — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
|
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }],
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Nexus — Resource & Capacity Planning",
|
title: "CapaKraken — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
@@ -60,56 +60,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script
|
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: `
|
||||||
nonce={nonce}
|
|
||||||
suppressHydrationWarning
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
try {
|
try {
|
||||||
if (!localStorage.getItem('nexus_migrated_v1')) {
|
var p = JSON.parse(localStorage.getItem('capakraken_theme') || '{}');
|
||||||
var underscoreKeys = ['theme','sidebar_collapsed','mfa_prompt_snoozed_until','prefs','pwa_dismiss'];
|
|
||||||
underscoreKeys.forEach(function(k){
|
|
||||||
var oldK = 'capakraken_' + k, newK = 'nexus_' + k;
|
|
||||||
var v = localStorage.getItem(oldK);
|
|
||||||
if (v !== null && localStorage.getItem(newK) === null) localStorage.setItem(newK, v);
|
|
||||||
localStorage.removeItem(oldK);
|
|
||||||
});
|
|
||||||
var dashKeys = [];
|
|
||||||
for (var i = 0; i < localStorage.length; i++) {
|
|
||||||
var lk = localStorage.key(i);
|
|
||||||
if (lk && lk.indexOf('capakraken_dashboard_v1_') === 0) dashKeys.push(lk);
|
|
||||||
}
|
|
||||||
dashKeys.forEach(function(lk){
|
|
||||||
var newLk = 'nexus_' + lk.substring('capakraken_'.length);
|
|
||||||
var v = localStorage.getItem(lk);
|
|
||||||
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
|
||||||
localStorage.removeItem(lk);
|
|
||||||
});
|
|
||||||
['capakraken-chat-messages','capakraken-chat-conversation-id'].forEach(function(lk){
|
|
||||||
var newLk = 'nexus-' + lk.substring('capakraken-'.length);
|
|
||||||
var v = localStorage.getItem(lk);
|
|
||||||
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
|
||||||
localStorage.removeItem(lk);
|
|
||||||
});
|
|
||||||
var av = localStorage.getItem('capakraken:allocations:viewMode');
|
|
||||||
if (av !== null && localStorage.getItem('nexus:allocations:viewMode') === null) {
|
|
||||||
localStorage.setItem('nexus:allocations:viewMode', av);
|
|
||||||
}
|
|
||||||
localStorage.removeItem('capakraken:allocations:viewMode');
|
|
||||||
localStorage.setItem('nexus_migrated_v1', '1');
|
|
||||||
if (typeof caches !== 'undefined') caches.delete('capakraken-v2');
|
|
||||||
}
|
|
||||||
var p = JSON.parse(localStorage.getItem('nexus_theme') || '{}');
|
|
||||||
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
||||||
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
`,
|
`}} />
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
||||||
className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}
|
|
||||||
>
|
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||||
import { createFirstAdmin } from "./actions.js";
|
import { createFirstAdmin } from "./actions.js";
|
||||||
|
|
||||||
export function SetupClient() {
|
export function SetupClient() {
|
||||||
@@ -76,7 +76,7 @@ export function SetupClient() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Create the initial administrator account for Nexus.
|
Create the initial administrator account for CapaKraken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { SystemRole } from "@nexus/db";
|
import { SystemRole } from "@capakraken/db";
|
||||||
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
import {
|
||||||
|
PASSWORD_MAX_LENGTH,
|
||||||
|
PASSWORD_MIN_LENGTH,
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
checkPasswordPolicy,
|
||||||
|
} from "@capakraken/shared";
|
||||||
|
|
||||||
export type SetupResult =
|
export type SetupResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
@@ -22,6 +27,13 @@ export async function createFirstAdmin(formData: {
|
|||||||
) {
|
) {
|
||||||
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
||||||
}
|
}
|
||||||
|
const policy = checkPasswordPolicy(formData.password, {
|
||||||
|
email: formData.email,
|
||||||
|
name: formData.name,
|
||||||
|
});
|
||||||
|
if (!policy.ok) {
|
||||||
|
return { error: "validation", message: policy.reason };
|
||||||
|
}
|
||||||
|
|
||||||
// TOCTOU guard — check again inside the action
|
// TOCTOU guard — check again inside the action
|
||||||
const count = await prisma.user.count();
|
const count = await prisma.user.count();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { prisma } from "@nexus/db";
|
import { prisma } from "@capakraken/db";
|
||||||
import { SetupClient } from "./SetupClient.js";
|
import { SetupClient } from "./SetupClient.js";
|
||||||
|
|
||||||
export default async function SetupPage() {
|
export default async function SetupPage() {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
|
|||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
||||||
import { assertSpreadsheetFile } from "~/lib/excel.js";
|
import { assertSpreadsheetFile } from "~/lib/excel.js";
|
||||||
import type { SkillEntry } from "@nexus/shared";
|
import type { SkillEntry } from "@capakraken/shared";
|
||||||
|
|
||||||
interface ParsedEntry {
|
interface ParsedEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
candidateEid: string; // guessed from filename (no extension, lowercased)
|
candidateEid: string; // guessed from filename (no extension, lowercased)
|
||||||
selectedEid: string;
|
selectedEid: string;
|
||||||
skills: SkillEntry[];
|
skills: SkillEntry[];
|
||||||
employeeInfo: Record<string, string>;
|
employeeInfo: Record<string, string>;
|
||||||
@@ -30,14 +30,8 @@ export function BatchSkillImport() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => { setResult(data); setSubmitting(false); },
|
||||||
setResult(data);
|
onError: (err) => { setError(err.message); setSubmitting(false); },
|
||||||
setSubmitting(false);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setError(err.message);
|
|
||||||
setSubmitting(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -78,8 +72,7 @@ export function BatchSkillImport() {
|
|||||||
|
|
||||||
const empInfo: Record<string, string> = {};
|
const empInfo: Record<string, string> = {};
|
||||||
if (roleId) empInfo["roleId"] = roleId;
|
if (roleId) empInfo["roleId"] = roleId;
|
||||||
if (result.employeeInfo.portfolioUrl)
|
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
||||||
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -131,9 +124,7 @@ export function BatchSkillImport() {
|
|||||||
skills: e.skills,
|
skills: e.skills,
|
||||||
employeeInfo: {
|
employeeInfo: {
|
||||||
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
||||||
...(e.employeeInfo["portfolioUrl"]
|
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
|
||||||
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -147,9 +138,7 @@ export function BatchSkillImport() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
|
||||||
Batch Skill Matrix Import
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
||||||
</p>
|
</p>
|
||||||
@@ -160,33 +149,12 @@ export function BatchSkillImport() {
|
|||||||
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
|
||||||
Click to select multiple .xlsx files
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
|
||||||
</p>
|
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
||||||
Name files after resource EID or display name for automatic matching
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept=".xlsx"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
@@ -198,9 +166,7 @@ export function BatchSkillImport() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
||||||
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
|
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
|
||||||
unmatched (select EID manually)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -211,39 +177,20 @@ export function BatchSkillImport() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
|
||||||
File
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
|
||||||
</th>
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
|
||||||
Resource EID
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Skills
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Role Match
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
{entries.map((entry, idx) => (
|
{entries.map((entry, idx) => (
|
||||||
<tr
|
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
|
||||||
key={idx}
|
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
|
||||||
className={
|
|
||||||
entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">
|
|
||||||
{entry.fileName}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{entry.status === "matched" ? (
|
{entry.status === "matched" ? (
|
||||||
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
|
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
|
||||||
{entry.selectedEid}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
@@ -252,27 +199,17 @@ export function BatchSkillImport() {
|
|||||||
>
|
>
|
||||||
<option value="">— Select resource —</option>
|
<option value="">— Select resource —</option>
|
||||||
{resourceList.map((r) => (
|
{resourceList.map((r) => (
|
||||||
<option key={r.eid} value={r.eid}>
|
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
|
||||||
{r.displayName} ({r.eid})
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
|
||||||
{entry.skills.length}
|
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{entry.matchedRoleName ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<span
|
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||||
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
||||||
entry.status === "matched"
|
}`}>
|
||||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
|
||||||
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.status}
|
{entry.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -284,15 +221,12 @@ export function BatchSkillImport() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||||
Import complete: <strong>{result.updated}</strong> updated,{" "}
|
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
|
||||||
<strong>{result.notFound}</strong> not found.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -303,9 +237,7 @@ export function BatchSkillImport() {
|
|||||||
disabled={submitting || matched === 0}
|
disabled={submitting || matched === 0}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
||||||
? "Importing…"
|
|
||||||
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SystemRole } from "@nexus/shared";
|
import { SystemRole } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
|
|
||||||
@@ -51,10 +51,7 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!email) {
|
if (!email) { setError("Email is required."); return; }
|
||||||
setError("Email is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await inviteMutation.mutateAsync({ email, role });
|
await inviteMutation.mutateAsync({ email, role });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +96,7 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
|||||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
>
|
>
|
||||||
{ROLE_OPTIONS.map((opt) => (
|
{ROLE_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PermissionKey } from "@nexus/shared";
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
|
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
|
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
|
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import type { PermissionKey } from "@nexus/shared";
|
import type { PermissionKey } from "@capakraken/shared";
|
||||||
import {
|
import {
|
||||||
SystemRole,
|
SystemRole,
|
||||||
ROLE_DEFAULT_PERMISSIONS,
|
ROLE_DEFAULT_PERMISSIONS,
|
||||||
MILLISECONDS_PER_DAY,
|
MILLISECONDS_PER_DAY,
|
||||||
type PermissionOverrides,
|
type PermissionOverrides,
|
||||||
} from "@nexus/shared";
|
} from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { InviteUserModal } from "./InviteUserModal.js";
|
import { InviteUserModal } from "./InviteUserModal.js";
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function WebhooksClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Configure outbound webhooks to notify external services about events in Nexus.
|
Configure outbound webhooks to notify external services about events in CapaKraken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||||
@@ -194,7 +194,10 @@ export function WebhooksClient() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{webhooks.map((wh) => (
|
{webhooks.map((wh) => (
|
||||||
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
|
<div
|
||||||
|
key={wh.id}
|
||||||
|
className="app-surface flex items-center gap-4 p-4"
|
||||||
|
>
|
||||||
{/* Active indicator */}
|
{/* Active indicator */}
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 shrink-0 rounded-full ${
|
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||||
@@ -206,7 +209,9 @@ export function WebhooksClient() {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{wh.name}
|
||||||
|
</span>
|
||||||
{wh.url.includes("hooks.slack.com") && (
|
{wh.url.includes("hooks.slack.com") && (
|
||||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
Slack
|
Slack
|
||||||
@@ -252,12 +257,17 @@ export function WebhooksClient() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={SECONDARY_BUTTON}
|
className={SECONDARY_BUTTON}
|
||||||
onClick={() => handleToggleActive(wh.id, wh.isActive)}
|
onClick={() =>
|
||||||
|
handleToggleActive(wh.id, wh.isActive)
|
||||||
|
}
|
||||||
disabled={updateMut.isPending}
|
disabled={updateMut.isPending}
|
||||||
>
|
>
|
||||||
{wh.isActive ? "Disable" : "Enable"}
|
{wh.isActive ? "Disable" : "Enable"}
|
||||||
</button>
|
</button>
|
||||||
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => openEditModal(wh)}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{deleteConfirmId === wh.id ? (
|
{deleteConfirmId === wh.id ? (
|
||||||
@@ -272,12 +282,18 @@ export function WebhooksClient() {
|
|||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
|
<button
|
||||||
|
className={SECONDARY_BUTTON}
|
||||||
|
onClick={() => setDeleteConfirmId(wh.id)}
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -319,7 +335,9 @@ export function WebhooksClient() {
|
|||||||
|
|
||||||
{/* Secret */}
|
{/* Secret */}
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>Secret (optional)</label>
|
<label className={LABEL_CLASS}>
|
||||||
|
Secret (optional)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import {
|
import {
|
||||||
INPUT_CLASS,
|
INPUT_CLASS,
|
||||||
@@ -123,9 +123,7 @@ export function AiProviderPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{urlParsedType === "completions" ? (
|
{urlParsedType === "completions" ? (
|
||||||
<p className="text-xs text-green-700 dark:text-green-400">
|
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
||||||
All fields filled from URL.
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,7 +154,7 @@ export function AiProviderPanel({
|
|||||||
id="ai-model"
|
id="ai-model"
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(event) => onModelChange(event.target.value)}
|
onChange={(event) => onModelChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +223,12 @@ export function AiProviderPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
{isSaving ? "Saving…" : "Save Settings"}
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -386,7 +389,12 @@ export function GenerationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={PRIMARY_BUTTON_CLASS}
|
||||||
|
>
|
||||||
{isSaving ? "Saving…" : "Save Settings"}
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
{saved ? (
|
{saved ? (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
|
|||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
value={smtpFrom}
|
value={smtpFrom}
|
||||||
onChange={(event) => setSmtpFrom(event.target.value)}
|
onChange={(event) => setSmtpFrom(event.target.value)}
|
||||||
placeholder="noreply@nexus.app"
|
placeholder="noreply@capakraken.app"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
|
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
|
||||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
||||||
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
|
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
|
||||||
import { formatDate } from "~/lib/format.js";
|
import { formatDate } from "~/lib/format.js";
|
||||||
import { AllocationRow } from "./AllocationRow.js";
|
import { AllocationRow } from "./AllocationRow.js";
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { AllocationStatus } from "@nexus/shared";
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
|
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
import { toDateInputValue } from "~/lib/format.js";
|
import { toDateInputValue } from "~/lib/format.js";
|
||||||
@@ -26,8 +26,7 @@ interface AllocationModalProps {
|
|||||||
|
|
||||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||||
const isEditing = Boolean(allocation);
|
const isEditing = Boolean(allocation);
|
||||||
const initialEntryKind: EntryKind =
|
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||||
allocation && !allocation.resourceId ? "demand" : "assignment";
|
|
||||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||||
const isDemandEntry = entryKind === "demand";
|
const isDemandEntry = entryKind === "demand";
|
||||||
|
|
||||||
@@ -58,8 +57,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
|
const { data: projects } = trpc.project.list.useQuery(
|
||||||
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
{ limit: 500 },
|
||||||
|
{ staleTime: 60_000 },
|
||||||
|
);
|
||||||
|
const { data: rolesData } = trpc.role.list.useQuery(
|
||||||
|
{ isActive: true },
|
||||||
|
{ staleTime: 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch existing allocations for the selected resource+project to detect overlaps
|
// Fetch existing allocations for the selected resource+project to detect overlaps
|
||||||
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
||||||
@@ -80,26 +85,20 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
const shouldCheckConflicts =
|
const shouldCheckConflicts =
|
||||||
!isDemandEntry &&
|
!isDemandEntry &&
|
||||||
!!debouncedResourceId &&
|
!!debouncedResourceId &&
|
||||||
conflictCheckStart !== null &&
|
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
|
||||||
!isNaN(conflictCheckStart.getTime()) &&
|
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
|
||||||
conflictCheckEnd !== null &&
|
|
||||||
!isNaN(conflictCheckEnd.getTime()) &&
|
|
||||||
debouncedHoursPerDay > 0;
|
debouncedHoursPerDay > 0;
|
||||||
const { data: conflictResult, isFetching: checkingConflicts } =
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
|
||||||
(trpc.allocation.checkConflicts.useQuery as any)(
|
{
|
||||||
{
|
resourceId: debouncedResourceId,
|
||||||
resourceId: debouncedResourceId,
|
startDate: conflictCheckStart,
|
||||||
startDate: conflictCheckStart,
|
endDate: conflictCheckEnd,
|
||||||
endDate: conflictCheckEnd,
|
hoursPerDay: debouncedHoursPerDay,
|
||||||
hoursPerDay: debouncedHoursPerDay,
|
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
},
|
||||||
},
|
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
|
||||||
) as {
|
|
||||||
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
|
|
||||||
isFetching: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const overlapWarning = useMemo(() => {
|
const overlapWarning = useMemo(() => {
|
||||||
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
||||||
@@ -107,17 +106,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
const formEnd = new Date(endDate);
|
const formEnd = new Date(endDate);
|
||||||
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
||||||
|
|
||||||
const allocList =
|
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
|
||||||
(
|
|
||||||
existingAllocations as {
|
|
||||||
allocations?: Array<{
|
|
||||||
id: string;
|
|
||||||
resourceId?: string | null;
|
|
||||||
startDate: string | Date;
|
|
||||||
endDate: string | Date;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
).allocations ?? [];
|
|
||||||
for (const existing of allocList) {
|
for (const existing of allocList) {
|
||||||
// Skip the allocation being edited
|
// Skip the allocation being edited
|
||||||
if (isEditing && allocation && existing.id === allocation.id) continue;
|
if (isEditing && allocation && existing.id === allocation.id) continue;
|
||||||
@@ -132,15 +121,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [
|
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
|
||||||
shouldCheckOverlap,
|
|
||||||
existingAllocations,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
isEditing,
|
|
||||||
allocation,
|
|
||||||
resourceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
|
|
||||||
@@ -204,17 +185,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setServerError(null);
|
setServerError(null);
|
||||||
setOverbookingAcknowledged(false);
|
setOverbookingAcknowledged(false);
|
||||||
}, [
|
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
||||||
resourceId,
|
|
||||||
projectId,
|
|
||||||
roleId,
|
|
||||||
roleFreeText,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
hoursPerDay,
|
|
||||||
status,
|
|
||||||
entryKind,
|
|
||||||
]);
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -251,7 +222,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
// Determine role string from roleId if set
|
// Determine role string from roleId if set
|
||||||
const rolesList = rolesData ?? [];
|
const rolesList = rolesData ?? [];
|
||||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||||
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
|
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
||||||
|
|
||||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||||
|
|
||||||
@@ -259,14 +230,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
id: getPlanningEntryMutationId(allocation),
|
id: getPlanningEntryMutationId(allocation),
|
||||||
data: {
|
data: {
|
||||||
resourceId: isDemandEntry ? undefined : resourceId || undefined,
|
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
||||||
projectId,
|
projectId,
|
||||||
role: roleString,
|
role: roleString,
|
||||||
roleId: roleId || undefined,
|
roleId: roleId || undefined,
|
||||||
headcount: isDemandEntry ? headcount : 1,
|
headcount: isDemandEntry ? headcount : 1,
|
||||||
...(isDemandEntry && budgetEur
|
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
|
||||||
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
|
|
||||||
: {}),
|
|
||||||
startDate: start,
|
startDate: start,
|
||||||
endDate: end,
|
endDate: end,
|
||||||
hoursPerDay,
|
hoursPerDay,
|
||||||
@@ -310,22 +279,18 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
|
|
||||||
const resourceList = (resources?.resources ?? []) as Array<{
|
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
||||||
id: string;
|
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
||||||
displayName: string;
|
|
||||||
eid: string;
|
|
||||||
}>;
|
|
||||||
const projectList = (projects?.projects ?? []) as Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
shortCode: string;
|
|
||||||
}>;
|
|
||||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
||||||
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
data-testid="allocation-modal"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
@@ -368,9 +333,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{isDemandEntry && (
|
{isDemandEntry && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
||||||
Headcount:
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={headcount}
|
value={headcount}
|
||||||
@@ -381,9 +344,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
|
||||||
Budget (EUR):
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={budgetEur}
|
value={budgetEur}
|
||||||
@@ -402,8 +363,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{!isDemandEntry && (
|
{!isDemandEntry && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-resource" className={labelClass}>
|
<label htmlFor="modal-resource" className={labelClass}>
|
||||||
Resource <span className="text-red-500">*</span>
|
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||||
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-resource"
|
id="modal-resource"
|
||||||
@@ -425,8 +385,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{/* Project */}
|
{/* Project */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-project" className={labelClass}>
|
<label htmlFor="modal-project" className={labelClass}>
|
||||||
Project <span className="text-red-500">*</span>
|
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||||
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-project"
|
id="modal-project"
|
||||||
@@ -446,10 +405,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-role" className={labelClass}>
|
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
|
||||||
Role
|
|
||||||
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
id="modal-role"
|
id="modal-role"
|
||||||
value={roleId}
|
value={roleId}
|
||||||
@@ -478,43 +434,35 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{/* Dates */}
|
{/* Dates */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className={labelClass}>
|
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
|
||||||
Date Range <span className="text-red-500">*</span>
|
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
|
||||||
</span>
|
|
||||||
<DateRangePresets
|
|
||||||
onSelect={(s, e) => {
|
|
||||||
setStartDate(s);
|
|
||||||
setEndDate(e);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-start" className={labelClass}>
|
<label htmlFor="modal-start" className={labelClass}>
|
||||||
Start Date{" "}
|
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||||
<InfoTooltip content="First day of this allocation period (inclusive)." />
|
</label>
|
||||||
</label>
|
<DateInput
|
||||||
<DateInput
|
id="modal-start"
|
||||||
id="modal-start"
|
value={startDate}
|
||||||
value={startDate}
|
onChange={setStartDate}
|
||||||
onChange={setStartDate}
|
className={inputClass}
|
||||||
className={inputClass}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label htmlFor="modal-end" className={labelClass}>
|
||||||
<label htmlFor="modal-end" className={labelClass}>
|
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
</label>
|
||||||
</label>
|
<DateInput
|
||||||
<DateInput
|
id="modal-end"
|
||||||
id="modal-end"
|
value={endDate}
|
||||||
value={endDate}
|
onChange={setEndDate}
|
||||||
onChange={setEndDate}
|
min={startDate}
|
||||||
min={startDate}
|
className={inputClass}
|
||||||
className={inputClass}
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -522,8 +470,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-hours" className={labelClass}>
|
<label htmlFor="modal-hours" className={labelClass}>
|
||||||
Hours / Day
|
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||||
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="modal-hours"
|
id="modal-hours"
|
||||||
@@ -538,8 +485,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-status" className={labelClass}>
|
<label htmlFor="modal-status" className={labelClass}>
|
||||||
Status
|
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||||
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-status"
|
id="modal-status"
|
||||||
@@ -568,10 +514,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
}}
|
}}
|
||||||
className="rounded border-gray-300 dark:border-gray-600"
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||||
Recurring schedule
|
|
||||||
</span>
|
|
||||||
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
|
||||||
</label>
|
</label>
|
||||||
{isRecurring && (
|
{isRecurring && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -605,12 +548,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
)}
|
)}
|
||||||
{!conflictResult && checkingConflicts && (
|
{!conflictResult && checkingConflicts && (
|
||||||
<ConflictWarningPanel
|
<ConflictWarningPanel
|
||||||
result={{
|
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
|
||||||
isOverbooking: false,
|
|
||||||
overbooking: null,
|
|
||||||
vacationOverlap: [],
|
|
||||||
hasVacationOverlap: false,
|
|
||||||
}}
|
|
||||||
isLoading={true}
|
isLoading={true}
|
||||||
acknowledged={false}
|
acknowledged={false}
|
||||||
onAcknowledge={() => {}}
|
onAcknowledge={() => {}}
|
||||||
@@ -630,11 +568,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || hasUnacknowledgedOverbooking}
|
disabled={isPending || hasUnacknowledgedOverbooking}
|
||||||
title={
|
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
|
||||||
hasUnacknowledgedOverbooking
|
|
||||||
? "Acknowledge the overbooking warning above to proceed"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isPending ? "Saving…" : "Save"}
|
{isPending ? "Saving…" : "Save"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
||||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||||
|
|
||||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import type {
|
|||||||
AllocationWithDetails,
|
AllocationWithDetails,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
AllocationStatus,
|
AllocationStatus,
|
||||||
} from "@nexus/shared";
|
} from "@capakraken/shared";
|
||||||
import { ALLOCATION_COLUMNS } from "@nexus/shared";
|
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||||
@@ -328,7 +328,7 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
||||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
||||||
"nexus:allocations:viewMode",
|
"capakraken:allocations:viewMode",
|
||||||
"grouped",
|
"grouped",
|
||||||
);
|
);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AllocationConflictCheckResult } from "@nexus/shared";
|
import type { AllocationConflictCheckResult } from "@capakraken/shared";
|
||||||
|
|
||||||
const INITIAL_ROWS_SHOWN = 5;
|
const INITIAL_ROWS_SHOWN = 5;
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
|
|||||||
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
|
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
|
||||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||||
⚠ Overbooking on {result.overbooking.totalConflictDays} day
|
⚠ Overbooking on {result.overbooking.totalConflictDays} day
|
||||||
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
|
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
|
||||||
{result.overbooking.maxOverbookPercent}% over capacity)
|
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-amber-700 dark:text-amber-400">
|
<p className="mt-1 text-amber-700 dark:text-amber-400">
|
||||||
The resource already has allocations that exceed their daily capacity on the following
|
The resource already has allocations that exceed their daily capacity on the following days.
|
||||||
days. You can still save — check the box below to confirm.
|
You can still save — check the box below to confirm.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Day-by-day table */}
|
{/* Day-by-day table */}
|
||||||
@@ -65,10 +65,7 @@ export function ConflictWarningPanel({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleDays.map((day) => (
|
{visibleDays.map((day) => (
|
||||||
<tr
|
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
|
||||||
key={day.date}
|
|
||||||
className="border-b border-amber-100 dark:border-amber-900/50 last:border-0"
|
|
||||||
>
|
|
||||||
<td className="py-1 pr-4">{day.date}</td>
|
<td className="py-1 pr-4">{day.date}</td>
|
||||||
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
|
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
|
||||||
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
|
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
|
||||||
@@ -88,9 +85,7 @@ export function ConflictWarningPanel({
|
|||||||
onClick={() => setShowAllDays((v) => !v)}
|
onClick={() => setShowAllDays((v) => !v)}
|
||||||
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
|
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{showAllDays
|
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
||||||
? "Show less"
|
|
||||||
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -120,18 +115,11 @@ export function ConflictWarningPanel({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
{result.vacationOverlap.map((v, i) => (
|
{result.vacationOverlap.map((v, i) => (
|
||||||
<li
|
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400"
|
|
||||||
>
|
|
||||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
|
||||||
<span className="font-medium capitalize">
|
<span className="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
|
||||||
{v.type.replace(/_/g, " ").toLowerCase()}
|
|
||||||
</span>
|
|
||||||
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
|
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
|
||||||
<span>
|
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}</span>
|
||||||
{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useMemo } from "react";
|
import { useRef, useState, useMemo } from "react";
|
||||||
import { AllocationStatus } from "@nexus/shared";
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
@@ -75,11 +75,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const { data: resources } = trpc.resource.listStaff.useQuery(
|
const { data: resources } = trpc.resource.listStaff.useQuery(
|
||||||
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
) as {
|
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
|
||||||
data:
|
|
||||||
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
|
|
||||||
| undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
|
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
|
||||||
{
|
{
|
||||||
@@ -122,20 +118,17 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const lcrCents = selectedResource.lcrCents ?? 0;
|
const lcrCents = selectedResource.lcrCents ?? 0;
|
||||||
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
|
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
|
||||||
|
|
||||||
setPlanned((prev) => [
|
setPlanned((prev) => [...prev, {
|
||||||
...prev,
|
resourceId: selectedResource.id,
|
||||||
{
|
resourceName: selectedResource.displayName,
|
||||||
resourceId: selectedResource.id,
|
eid: selectedResource.eid,
|
||||||
resourceName: selectedResource.displayName,
|
hoursPerDay,
|
||||||
eid: selectedResource.eid,
|
availableHours: avail.totalAvailableHours,
|
||||||
hoursPerDay,
|
availableDays: avail.availableDays,
|
||||||
availableHours: avail.totalAvailableHours,
|
conflictDays: avail.conflictDays,
|
||||||
availableDays: avail.availableDays,
|
coveragePercent: avail.coveragePercent,
|
||||||
conflictDays: avail.conflictDays,
|
estimatedCostCents,
|
||||||
coveragePercent: avail.coveragePercent,
|
}]);
|
||||||
estimatedCostCents,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Reset for next resource
|
// Reset for next resource
|
||||||
setResourceId("");
|
setResourceId("");
|
||||||
@@ -167,9 +160,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
status: AllocationStatus.PROPOSED,
|
status: AllocationStatus.PROPOSED,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError(
|
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,16 +177,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||||
onClick={(e) => {
|
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
|
||||||
if (e.target === e.currentTarget && !submitting) onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
|
||||||
if (e.key === "Escape" && !submitting) onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
@@ -203,34 +190,21 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
||||||
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">×</button>
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pt-4 pb-2 space-y-3">
|
<div className="px-6 pt-4 pb-2 space-y-3">
|
||||||
{/* Demand summary */}
|
{/* Demand summary */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
|
||||||
<div
|
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
|
||||||
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
|
|
||||||
style={{ backgroundColor: roleColor }}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} –{" "}
|
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} – {formatDateMedium(allocation.endDate)}
|
||||||
{formatDateMedium(allocation.endDate)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
||||||
{allocation.budgetCents && allocation.budgetCents > 0
|
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
|
||||||
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
|
|
||||||
: ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,10 +213,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
<span>Demand coverage</span>
|
<span>Demand coverage</span>
|
||||||
<span>
|
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
|
||||||
{Math.round(consumedHours)}h / {totalDemandHours}h (
|
|
||||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
||||||
{planned.map((r, i) => (
|
{planned.map((r, i) => (
|
||||||
@@ -263,18 +234,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{planned.map((r, i) => (
|
{planned.map((r, i) => (
|
||||||
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
|
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
|
||||||
<div
|
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
|
||||||
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
|
||||||
{r.resourceName}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-400">({r.eid})</span>
|
<span className="text-gray-400">({r.eid})</span>
|
||||||
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
|
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
|
||||||
<span className="ml-auto text-gray-500">
|
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
|
||||||
{Math.round(r.availableHours)}h · {r.coveragePercent}%
|
|
||||||
</span>
|
|
||||||
{phase === "plan" && (
|
{phase === "plan" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -290,9 +254,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{remainingHours > 0 && (
|
{remainingHours > 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||||
<span className="text-amber-600 dark:text-amber-400 font-medium">
|
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
|
||||||
Remaining: {Math.round(remainingHours)}h
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,9 +266,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{phase === "plan" && (
|
{phase === "plan" && (
|
||||||
<div className="px-6 pb-5 space-y-4">
|
<div className="px-6 pb-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
|
||||||
Search Resource
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or EID..."
|
placeholder="Search by name or EID..."
|
||||||
@@ -317,9 +277,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
|
||||||
Select Resource
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={resourceId}
|
value={resourceId}
|
||||||
onChange={(e) => setResourceId(e.target.value)}
|
onChange={(e) => setResourceId(e.target.value)}
|
||||||
@@ -339,9 +297,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
|
||||||
Hours / Day
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={hoursPerDay}
|
value={hoursPerDay}
|
||||||
@@ -355,53 +311,41 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
|
|
||||||
{/* Availability preview */}
|
{/* Availability preview */}
|
||||||
{resourceId && avail && (
|
{resourceId && avail && (
|
||||||
<div
|
<div className={`rounded-lg p-3 border text-sm ${
|
||||||
className={`rounded-lg p-3 border text-sm ${
|
avail.coveragePercent >= 100
|
||||||
avail.coveragePercent >= 100
|
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
: avail.coveragePercent >= 50
|
||||||
: avail.coveragePercent >= 50
|
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
||||||
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||||
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
}`}>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
|
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
|
||||||
Availability: {avail.resource.name}
|
Availability: {avail.resource.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Available</span>
|
<span className="text-gray-500 dark:text-gray-400">Available</span>
|
||||||
<div className="font-semibold text-green-700 dark:text-green-400">
|
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
|
||||||
{avail.availableDays} days
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
|
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
|
||||||
<div className="font-semibold text-red-700 dark:text-red-400">
|
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
|
||||||
{avail.conflictDays} days
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Hours</span>
|
<span className="text-gray-500 dark:text-gray-400">Hours</span>
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
|
||||||
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{avail.existingAssignments.length > 0 && (
|
{avail.existingAssignments.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Existing bookings:</div>
|
||||||
Existing bookings:
|
|
||||||
</div>
|
|
||||||
{avail.existingAssignments.slice(0, 4).map((a, i) => (
|
{avail.existingAssignments.slice(0, 4).map((a, i) => (
|
||||||
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
|
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
|
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{avail.existingAssignments.length > 4 && (
|
{avail.existingAssignments.length > 4 && (
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
|
||||||
+{avail.existingAssignments.length - 4} more
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -409,18 +353,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{resourceId && availabilityQuery.isLoading && (
|
{resourceId && availabilityQuery.isLoading && (
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
|
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
|
||||||
Checking availability...
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center justify-between gap-3 pt-2">
|
<div className="flex items-center justify-between gap-3 pt-2">
|
||||||
<button
|
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -453,27 +391,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
|
||||||
Resource
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
|
||||||
h/day
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
Hours
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="inline-flex items-center justify-end gap-0.5">
|
|
||||||
Est. Cost
|
|
||||||
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="inline-flex items-center justify-end gap-0.5">
|
|
||||||
Coverage
|
|
||||||
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
@@ -483,19 +405,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{r.resourceName}
|
{r.resourceName}
|
||||||
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
|
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
|
||||||
{r.hoursPerDay}h
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
|
||||||
</td>
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
|
||||||
{Math.round(r.availableHours)}h
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
|
||||||
{formatCents(r.estimatedCostCents)} EUR
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<span
|
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
|
||||||
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
|
|
||||||
>
|
|
||||||
{r.coveragePercent}%
|
{r.coveragePercent}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -504,9 +418,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot className="bg-gray-50 dark:bg-gray-900">
|
<tfoot className="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
|
||||||
Total
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2" />
|
<td className="px-3 py-2" />
|
||||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{Math.round(consumedHours)}h / {totalDemandHours}h
|
{Math.round(consumedHours)}h / {totalDemandHours}h
|
||||||
@@ -515,20 +427,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{totalDemandHours > 0
|
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
|
||||||
? Math.round((consumedHours / totalDemandHours) * 100)
|
|
||||||
: 0}
|
|
||||||
%
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{allocation.budgetCents && allocation.budgetCents > 0 && (
|
{allocation.budgetCents && allocation.budgetCents > 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
|
||||||
colSpan={3}
|
|
||||||
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Role Budget:
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{formatCents(allocation.budgetCents)} EUR
|
{formatCents(allocation.budgetCents)} EUR
|
||||||
</td>
|
</td>
|
||||||
@@ -537,12 +441,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
|
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
|
||||||
const remain = allocation.budgetCents! - totalCost;
|
const remain = allocation.budgetCents! - totalCost;
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
|
||||||
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
|
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
|
||||||
>
|
|
||||||
{remain < 0
|
|
||||||
? `${formatCents(Math.abs(remain))} over`
|
|
||||||
: `${formatCents(remain)} left`}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -555,8 +455,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
|
|
||||||
{remainingHours > 0 && (
|
{remainingHours > 0 && (
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
|
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
|
||||||
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
|
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
|
||||||
partially.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -587,9 +486,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
disabled={submitting || planned.length === 0}
|
disabled={submitting || planned.length === 0}
|
||||||
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
|
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
||||||
? `Assigning ${submitProgress}/${planned.length}...`
|
|
||||||
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails } from "@nexus/shared";
|
import type { AllocationWithDetails } from "@capakraken/shared";
|
||||||
|
|
||||||
type DemandRow = AllocationWithDetails & {
|
type DemandRow = AllocationWithDetails & {
|
||||||
sourceAllocationId?: string;
|
sourceAllocationId?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RecurrenceFrequency } from "@nexus/shared";
|
import { RecurrenceFrequency } from "@capakraken/shared";
|
||||||
import type { RecurrencePattern } from "@nexus/shared";
|
import type { RecurrencePattern } from "@capakraken/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
@@ -39,10 +39,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
{/* Frequency selector */}
|
{/* Frequency selector */}
|
||||||
<div>
|
<div>
|
||||||
<span className={labelClass}>
|
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
|
||||||
Frequency
|
|
||||||
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{Object.values(RecurrenceFrequency).map((f) => (
|
{Object.values(RecurrenceFrequency).map((f) => (
|
||||||
<button
|
<button
|
||||||
@@ -58,10 +55,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{f === RecurrenceFrequency.WEEKLY
|
{f === RecurrenceFrequency.WEEKLY
|
||||||
? "Weekly"
|
? "Weekly"
|
||||||
: f === RecurrenceFrequency.BIWEEKLY
|
: f === RecurrenceFrequency.BIWEEKLY
|
||||||
? "Biweekly"
|
? "Biweekly"
|
||||||
: f === RecurrenceFrequency.MONTHLY
|
: f === RecurrenceFrequency.MONTHLY
|
||||||
? "Monthly"
|
? "Monthly"
|
||||||
: "Custom"}
|
: "Custom"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,10 +67,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||||
<div>
|
<div>
|
||||||
<span className={labelClass}>
|
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
|
||||||
Days of week
|
|
||||||
<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." />
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{WEEKDAY_LABELS.map((label, dow) => {
|
{WEEKDAY_LABELS.map((label, dow) => {
|
||||||
const selected = (value?.weekdays ?? []).includes(dow);
|
const selected = (value?.weekdays ?? []).includes(dow);
|
||||||
@@ -145,10 +139,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>
|
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
|
||||||
Hours per recurring day (optional override)
|
|
||||||
<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." />
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.5}
|
min={0.5}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ interface AssistantInsight {
|
|||||||
sections?: AssistantInsightSection[];
|
sections?: AssistantInsightSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "nexus-chat-messages";
|
const STORAGE_KEY = "capakraken-chat-messages";
|
||||||
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
|
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
|
||||||
|
|
||||||
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { FieldType } from "@nexus/shared";
|
import { FieldType } from "@capakraken/shared";
|
||||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||||
import { FieldCard } from "./FieldCard.js";
|
import { FieldCard } from "./FieldCard.js";
|
||||||
@@ -48,7 +48,10 @@ interface FieldState {
|
|||||||
// Helpers: Convert between FieldState and BlueprintFieldDefinition
|
// Helpers: Convert between FieldState and BlueprintFieldDefinition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
|
function fieldDefToState(
|
||||||
|
def: BlueprintFieldDefinition,
|
||||||
|
target: BlueprintTargetValue,
|
||||||
|
): FieldState {
|
||||||
const catalogField = findCatalogField(target, def.key);
|
const catalogField = findCatalogField(target, def.key);
|
||||||
if (catalogField) {
|
if (catalogField) {
|
||||||
return {
|
return {
|
||||||
@@ -183,7 +186,9 @@ export function BlueprintFieldCatalog({
|
|||||||
// Build initial state from existing fieldDefs + catalog
|
// Build initial state from existing fieldDefs + catalog
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
|
const [catalogOverrides, setCatalogOverrides] = useState<
|
||||||
|
Record<string, FieldOverrides>
|
||||||
|
>(() => {
|
||||||
const map: Record<string, FieldOverrides> = {};
|
const map: Record<string, FieldOverrides> = {};
|
||||||
// Start with all catalog fields disabled
|
// Start with all catalog fields disabled
|
||||||
for (const cf of catalog) {
|
for (const cf of catalog) {
|
||||||
@@ -264,13 +269,21 @@ export function BlueprintFieldCatalog({
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
|
const handleCatalogFieldChange = useCallback(
|
||||||
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
(key: string, overrides: FieldOverrides) => {
|
||||||
}, []);
|
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
|
const handleCustomFieldChange = useCallback(
|
||||||
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
|
(idx: number, overrides: FieldOverrides) => {
|
||||||
}, []);
|
setCustomFields((prev) =>
|
||||||
|
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
function removeCustomField(idx: number) {
|
function removeCustomField(idx: number) {
|
||||||
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
|
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
|
||||||
@@ -357,7 +370,9 @@ export function BlueprintFieldCatalog({
|
|||||||
// Collapsed categories
|
// Collapsed categories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
function toggleCategory(name: string) {
|
function toggleCategory(name: string) {
|
||||||
setCollapsedCategories((prev) => {
|
setCollapsedCategories((prev) => {
|
||||||
@@ -487,16 +502,15 @@ export function BlueprintFieldCatalog({
|
|||||||
{/* Field cards */}
|
{/* Field cards */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
|
||||||
{categories
|
{categories
|
||||||
.filter((cat) => activeCategory === null || activeCategory === cat.name)
|
.filter(
|
||||||
|
(cat) =>
|
||||||
|
activeCategory === null ||
|
||||||
|
activeCategory === cat.name,
|
||||||
|
)
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const fields = fieldsByCategory.get(cat.name) ?? [];
|
const fields = fieldsByCategory.get(cat.name) ?? [];
|
||||||
if (fields.length === 0 && searchQuery.trim()) return null;
|
if (fields.length === 0 && searchQuery.trim()) return null;
|
||||||
if (
|
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
|
||||||
fields.length === 0 &&
|
|
||||||
activeCategory !== null &&
|
|
||||||
activeCategory !== cat.name
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const isCollapsed = collapsedCategories.has(cat.name);
|
const isCollapsed = collapsedCategories.has(cat.name);
|
||||||
|
|
||||||
@@ -513,7 +527,9 @@ export function BlueprintFieldCatalog({
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">{cat.description}</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{cat.description}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
@@ -522,7 +538,9 @@ export function BlueprintFieldCatalog({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
overrides={catalogOverrides[field.key]!}
|
overrides={catalogOverrides[field.key]!}
|
||||||
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
|
onChange={(ov) =>
|
||||||
|
handleCatalogFieldChange(field.key, ov)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
@@ -537,7 +555,8 @@ export function BlueprintFieldCatalog({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom Fields section */}
|
{/* Custom Fields section */}
|
||||||
{(activeCategory === null || activeCategory === "Custom Fields") && (
|
{(activeCategory === null ||
|
||||||
|
activeCategory === "Custom Fields") && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -545,7 +564,9 @@ export function BlueprintFieldCatalog({
|
|||||||
className="flex items-center gap-2 mb-3 w-full text-left group"
|
className="flex items-center gap-2 mb-3 w-full text-left group"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
|
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
|
||||||
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
|
{collapsedCategories.has("Custom Fields")
|
||||||
|
? "\u25B6"
|
||||||
|
: "\u25BC"}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Custom Fields
|
Custom Fields
|
||||||
@@ -564,7 +585,8 @@ export function BlueprintFieldCatalog({
|
|||||||
label: cf.custom.label,
|
label: cf.custom.label,
|
||||||
type: cf.custom.type,
|
type: cf.custom.type,
|
||||||
category: "Custom Fields",
|
category: "Custom Fields",
|
||||||
description: cf.overrides.description || "Custom field",
|
description:
|
||||||
|
cf.overrides.description || "Custom field",
|
||||||
...(cf.custom.options.length > 0
|
...(cf.custom.options.length > 0
|
||||||
? { options: cf.custom.options }
|
? { options: cf.custom.options }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -575,7 +597,9 @@ export function BlueprintFieldCatalog({
|
|||||||
<FieldCard
|
<FieldCard
|
||||||
field={pseudoCatalog}
|
field={pseudoCatalog}
|
||||||
overrides={cf.overrides}
|
overrides={cf.overrides}
|
||||||
onChange={(ov) => handleCustomFieldChange(idx, ov)}
|
onChange={(ov) =>
|
||||||
|
handleCustomFieldChange(idx, ov)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -595,13 +619,19 @@ export function BlueprintFieldCatalog({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">
|
<label className="text-xs font-medium text-gray-600">
|
||||||
Key <span className="text-red-500">*</span>
|
Key{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customKey}
|
value={customKey}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
|
setCustomKey(
|
||||||
|
e.target.value.replace(
|
||||||
|
/[^a-zA-Z0-9_]/g,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
placeholder="field_key"
|
placeholder="field_key"
|
||||||
className="app-input font-mono"
|
className="app-input font-mono"
|
||||||
@@ -609,21 +639,30 @@ export function BlueprintFieldCatalog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">
|
<label className="text-xs font-medium text-gray-600">
|
||||||
Label <span className="text-red-500">*</span>
|
Label{" "}
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customLabel}
|
value={customLabel}
|
||||||
onChange={(e) => setCustomLabel(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setCustomLabel(e.target.value)
|
||||||
|
}
|
||||||
placeholder="Display Label"
|
placeholder="Display Label"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">Type</label>
|
<label className="text-xs font-medium text-gray-600">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={customType}
|
value={customType}
|
||||||
onChange={(e) => setCustomType(e.target.value as FieldType)}
|
onChange={(e) =>
|
||||||
|
setCustomType(
|
||||||
|
e.target.value as FieldType,
|
||||||
|
)
|
||||||
|
}
|
||||||
className="app-input"
|
className="app-input"
|
||||||
>
|
>
|
||||||
{FIELD_TYPES.map((ft) => (
|
{FIELD_TYPES.map((ft) => (
|
||||||
@@ -638,7 +677,9 @@ export function BlueprintFieldCatalog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addCustomField}
|
onClick={addCustomField}
|
||||||
disabled={!customKey.trim() || !customLabel.trim()}
|
disabled={
|
||||||
|
!customKey.trim() || !customLabel.trim()
|
||||||
|
}
|
||||||
className={BTN_PRIMARY}
|
className={BTN_PRIMARY}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
@@ -663,7 +704,8 @@ export function BlueprintFieldCatalog({
|
|||||||
onClick={() => setShowCustomForm(true)}
|
onClick={() => setShowCustomForm(true)}
|
||||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
|
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">+</span> Add Custom Field
|
<span className="text-lg leading-none">+</span>{" "}
|
||||||
|
Add Custom Field
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -684,7 +726,8 @@ export function BlueprintFieldCatalog({
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
|
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
|
||||||
|
saved
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||||
@@ -704,8 +747,8 @@ export function BlueprintFieldCatalog({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
Role presets are auto-loaded in Step 3 of the Project Creation
|
||||||
blueprint is selected.
|
Wizard when this blueprint is selected.
|
||||||
</p>
|
</p>
|
||||||
<RolePresetsEditor
|
<RolePresetsEditor
|
||||||
initialPresets={initialRolePresets}
|
initialPresets={initialRolePresets}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldType } from "@nexus/shared";
|
import { FieldType } from "@capakraken/shared";
|
||||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||||
|
|
||||||
@@ -53,7 +53,9 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateOption(idx: number, field: "value" | "label", val: string) {
|
function updateOption(idx: number, field: "value" | "label", val: string) {
|
||||||
const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
|
const next = options.map((o, i) =>
|
||||||
|
i === idx ? { ...o, [field]: val } : o,
|
||||||
|
);
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +111,8 @@ interface FieldRowProps {
|
|||||||
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
const needsOptions =
|
||||||
|
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||||
|
|
||||||
function update<K extends keyof BlueprintFieldDefinition>(
|
function update<K extends keyof BlueprintFieldDefinition>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -123,7 +126,9 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Drag handle placeholder */}
|
{/* Drag handle placeholder */}
|
||||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">☰</span>
|
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
|
||||||
|
☰
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Key */}
|
{/* Key */}
|
||||||
<input
|
<input
|
||||||
@@ -153,7 +158,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
// Clear options when switching away from select types
|
// Clear options when switching away from select types
|
||||||
const clearedOptions =
|
const clearedOptions =
|
||||||
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
||||||
? (field.options ?? [])
|
? field.options ?? []
|
||||||
: undefined;
|
: undefined;
|
||||||
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
||||||
}}
|
}}
|
||||||
@@ -213,21 +218,29 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500 font-medium">Placeholder</label>
|
<label className="text-xs text-gray-500 font-medium">
|
||||||
|
Placeholder
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={field.placeholder ?? ""}
|
value={field.placeholder ?? ""}
|
||||||
onChange={(e) => update("placeholder", e.target.value || undefined)}
|
onChange={(e) =>
|
||||||
|
update("placeholder", e.target.value || undefined)
|
||||||
|
}
|
||||||
placeholder="Placeholder text"
|
placeholder="Placeholder text"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500 font-medium">Description</label>
|
<label className="text-xs text-gray-500 font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={field.description ?? ""}
|
value={field.description ?? ""}
|
||||||
onChange={(e) => update("description", e.target.value || undefined)}
|
onChange={(e) =>
|
||||||
|
update("description", e.target.value || undefined)
|
||||||
|
}
|
||||||
placeholder="Helper text"
|
placeholder="Helper text"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
@@ -298,8 +311,9 @@ export function BlueprintFieldEditor({
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
||||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
|
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
|
||||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
() =>
|
||||||
|
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||||
);
|
);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
||||||
@@ -313,11 +327,17 @@ export function BlueprintFieldEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeField(idx: number) {
|
function removeField(idx: number) {
|
||||||
setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
|
setFields((prev) =>
|
||||||
|
prev
|
||||||
|
.filter((_, i) => i !== idx)
|
||||||
|
.map((f, i) => ({ ...f, order: i })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
||||||
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
|
setFields((prev) =>
|
||||||
|
prev.map((f, i) => (i === idx ? updated : f)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@@ -355,7 +375,8 @@ export function BlueprintFieldEditor({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
|
Edit Fields:{" "}
|
||||||
|
<span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -440,8 +461,7 @@ export function BlueprintFieldEditor({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
|
||||||
blueprint is selected.
|
|
||||||
</p>
|
</p>
|
||||||
<RolePresetsEditor
|
<RolePresetsEditor
|
||||||
initialPresets={initialRolePresets}
|
initialPresets={initialRolePresets}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import type { BlueprintTarget } from "@nexus/shared";
|
import type { BlueprintTarget } from "@capakraken/shared";
|
||||||
import type { BlueprintFieldDefinition } from "@nexus/shared";
|
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
|
|||||||
}
|
}
|
||||||
initialRolePresets={
|
initialRolePresets={
|
||||||
Array.isArray(editingBlueprint.rolePresets)
|
Array.isArray(editingBlueprint.rolePresets)
|
||||||
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
|
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
initialTab={editingTab}
|
initialTab={editingTab}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldType } from "@nexus/shared";
|
import { FieldType } from "@capakraken/shared";
|
||||||
import type { FieldOption } from "@nexus/shared";
|
import type { FieldOption } from "@capakraken/shared";
|
||||||
import type { CatalogField } from "~/lib/blueprint-field-catalog.js";
|
import type { CatalogField } from "~/lib/blueprint-field-catalog.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -234,7 +234,9 @@ function DefaultValueInput({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={value != null ? String(value) : ""}
|
value={value != null ? String(value) : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : Number(e.target.value))}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : Number(e.target.value))
|
||||||
|
}
|
||||||
placeholder="No default"
|
placeholder="No default"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
@@ -245,7 +247,9 @@ function DefaultValueInput({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -254,7 +258,9 @@ function DefaultValueInput({
|
|||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
className="app-input"
|
className="app-input"
|
||||||
>
|
>
|
||||||
<option value="">No default</option>
|
<option value="">No default</option>
|
||||||
@@ -280,7 +286,9 @@ function DefaultValueInput({
|
|||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
@@ -291,7 +299,9 @@ function DefaultValueInput({
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
@@ -301,7 +311,9 @@ function DefaultValueInput({
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
placeholder="No default"
|
placeholder="No default"
|
||||||
className="app-input resize-none"
|
className="app-input resize-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -313,7 +325,9 @@ function DefaultValueInput({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
onChange={(e) => onChange(e.target.value === "" ? undefined : e.target.value)}
|
onChange={(e) =>
|
||||||
|
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||||
|
}
|
||||||
placeholder="No default"
|
placeholder="No default"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { StaffingRequirement } from "@nexus/shared";
|
import type { StaffingRequirement } from "@capakraken/shared";
|
||||||
import { uuid } from "~/lib/uuid.js";
|
import { uuid } from "~/lib/uuid.js";
|
||||||
|
|
||||||
function makeEmptyPreset(): StaffingRequirement {
|
function makeEmptyPreset(): StaffingRequirement {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CommentEntityType } from "@nexus/shared";
|
import type { CommentEntityType } from "@capakraken/shared";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -39,17 +39,14 @@ export function CommentInput({
|
|||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const usersQuery = trpc.comment.listMentionCandidates.useQuery(
|
const usersQuery = trpc.comment.listMentionCandidates.useQuery({
|
||||||
{
|
entityType,
|
||||||
entityType,
|
entityId,
|
||||||
entityId,
|
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
|
||||||
...(mentionQuery && mentionQuery.length > 0 ? { query: mentionQuery } : {}),
|
}, {
|
||||||
},
|
enabled: mentionQuery !== null,
|
||||||
{
|
staleTime: 60_000,
|
||||||
enabled: mentionQuery !== null,
|
});
|
||||||
staleTime: 60_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredUsers: MentionCandidate[] =
|
const filteredUsers: MentionCandidate[] =
|
||||||
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
|
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
|
||||||
@@ -66,22 +63,25 @@ export function CommentInput({
|
|||||||
setMentionIndex(0);
|
setMentionIndex(0);
|
||||||
}, [mentionQuery]);
|
}, [mentionQuery]);
|
||||||
|
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = useCallback(
|
||||||
const value = e.target.value;
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const cursor = e.target.selectionStart ?? value.length;
|
const value = e.target.value;
|
||||||
setBody(value);
|
const cursor = e.target.selectionStart ?? value.length;
|
||||||
setCursorPosition(cursor);
|
setBody(value);
|
||||||
|
setCursorPosition(cursor);
|
||||||
|
|
||||||
// Detect if we are in a @mention context
|
// Detect if we are in a @mention context
|
||||||
const textBeforeCursor = value.slice(0, cursor);
|
const textBeforeCursor = value.slice(0, cursor);
|
||||||
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
|
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
|
||||||
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
setMentionQuery(atMatch[1]!);
|
setMentionQuery(atMatch[1]!);
|
||||||
} else {
|
} else {
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const insertMention = useCallback(
|
const insertMention = useCallback(
|
||||||
(user: MentionCandidate) => {
|
(user: MentionCandidate) => {
|
||||||
@@ -96,7 +96,8 @@ export function CommentInput({
|
|||||||
const displayName = user.name ?? user.email;
|
const displayName = user.name ?? user.email;
|
||||||
const mentionText = `@[${displayName}](${user.id}) `;
|
const mentionText = `@[${displayName}](${user.id}) `;
|
||||||
|
|
||||||
const newBody = textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
|
const newBody =
|
||||||
|
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
|
||||||
setBody(newBody);
|
setBody(newBody);
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
|
|
||||||
@@ -120,12 +121,16 @@ export function CommentInput({
|
|||||||
if (mentionQuery !== null && filteredUsers.length > 0) {
|
if (mentionQuery !== null && filteredUsers.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMentionIndex((prev) => (prev < filteredUsers.length - 1 ? prev + 1 : 0));
|
setMentionIndex((prev) =>
|
||||||
|
prev < filteredUsers.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowUp") {
|
if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMentionIndex((prev) => (prev > 0 ? prev - 1 : filteredUsers.length - 1));
|
setMentionIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : filteredUsers.length - 1,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" || e.key === "Tab") {
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
@@ -213,7 +218,9 @@ export function CommentInput({
|
|||||||
: null}
|
: null}
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-400">Ctrl+Enter to submit</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
Ctrl+Enter to submit
|
||||||
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CommentEntityType } from "@nexus/shared";
|
import type { CommentEntityType } from "@capakraken/shared";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -150,7 +150,12 @@ function SingleComment({
|
|||||||
const isResolved = comment.resolved;
|
const isResolved = comment.resolved;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("group relative", isResolved && "opacity-60")}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"group relative",
|
||||||
|
isResolved && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className={clsx("flex gap-3", isReply && "ml-10")}>
|
<div className={clsx("flex gap-3", isReply && "ml-10")}>
|
||||||
<AuthorAvatar author={comment.author} />
|
<AuthorAvatar author={comment.author} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -158,7 +163,9 @@ function SingleComment({
|
|||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{comment.author.name ?? comment.author.email}
|
{comment.author.name ?? comment.author.email}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">{formatRelativeTime(comment.createdAt)}</span>
|
<span className="text-xs text-gray-400">
|
||||||
|
{formatRelativeTime(comment.createdAt)}
|
||||||
|
</span>
|
||||||
{isResolved && (
|
{isResolved && (
|
||||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||||
Resolved
|
Resolved
|
||||||
@@ -166,11 +173,7 @@ function SingleComment({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
|
||||||
className={clsx(
|
|
||||||
isResolved && "line-through decoration-gray-300 dark:decoration-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CommentBody body={comment.body} />
|
<CommentBody body={comment.body} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -213,17 +216,17 @@ function SingleComment({
|
|||||||
{/* Inline reply input */}
|
{/* Inline reply input */}
|
||||||
{showReplyInput && (
|
{showReplyInput && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<CommentInput
|
<CommentInput
|
||||||
entityType={commentTarget.entityType}
|
entityType={commentTarget.entityType}
|
||||||
entityId={commentTarget.entityId}
|
entityId={commentTarget.entityId}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
onSubmit={(replyBody) => {
|
onSubmit={(replyBody) => {
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
entityType: commentTarget.entityType,
|
entityType: commentTarget.entityType,
|
||||||
entityId: commentTarget.entityId,
|
entityId: commentTarget.entityId,
|
||||||
parentId: comment.id,
|
parentId: comment.id,
|
||||||
body: replyBody,
|
body: replyBody,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onCancel={() => setShowReplyInput(false)}
|
onCancel={() => setShowReplyInput(false)}
|
||||||
isSubmitting={createMutation.isPending}
|
isSubmitting={createMutation.isPending}
|
||||||
@@ -253,7 +256,12 @@ function SingleComment({
|
|||||||
{"replies" in comment && comment.replies.length > 0 && (
|
{"replies" in comment && comment.replies.length > 0 && (
|
||||||
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
|
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
|
||||||
{comment.replies.map((reply) => (
|
{comment.replies.map((reply) => (
|
||||||
<SingleComment key={reply.id} comment={reply} commentTarget={commentTarget} isReply />
|
<SingleComment
|
||||||
|
key={reply.id}
|
||||||
|
comment={reply}
|
||||||
|
commentTarget={commentTarget}
|
||||||
|
isReply
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -264,7 +272,10 @@ function SingleComment({
|
|||||||
export function CommentThread({ commentTarget }: CommentThreadProps) {
|
export function CommentThread({ commentTarget }: CommentThreadProps) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const commentsQuery = trpc.comment.list.useQuery(commentTarget, { staleTime: 10_000 });
|
const commentsQuery = trpc.comment.list.useQuery(
|
||||||
|
commentTarget,
|
||||||
|
{ staleTime: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
const createMutation = trpc.comment.create.useMutation({
|
const createMutation = trpc.comment.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -297,7 +308,11 @@ export function CommentThread({ commentTarget }: CommentThreadProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<SingleComment key={comment.id} comment={comment} commentTarget={commentTarget} />
|
<SingleComment
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
commentTarget={commentTarget}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardWidgetType } from "@nexus/shared/types";
|
import type { DashboardWidgetType } from "@capakraken/shared/types";
|
||||||
import { WIDGET_CATALOG } from "./widget-registry.js";
|
import { WIDGET_CATALOG } from "./widget-registry.js";
|
||||||
|
|
||||||
interface AddWidgetModalProps {
|
interface AddWidgetModalProps {
|
||||||
@@ -44,12 +44,8 @@ export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
|
|||||||
>
|
>
|
||||||
<span className="text-3xl shrink-0">{def.icon}</span>
|
<span className="text-3xl shrink-0">{def.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">
|
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm">{def.label}</div>
|
||||||
{def.label}
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{def.description}</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{def.description}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
|
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { DashboardWidgetConfig, DashboardWidgetType } from "@nexus/shared/types";
|
import type { DashboardWidgetConfig, DashboardWidgetType } from "@capakraken/shared/types";
|
||||||
import { verticalCompactor, horizontalCompactor, type Compactor } from "react-grid-layout";
|
import { verticalCompactor, horizontalCompactor, type Compactor } from "react-grid-layout";
|
||||||
|
|
||||||
// Runs vertical compaction first (float up), then horizontal (float left).
|
// Runs vertical compaction first (float up), then horizontal (float left).
|
||||||
@@ -152,25 +152,13 @@ function DeferredWidgetBody({
|
|||||||
};
|
};
|
||||||
}, [activationRank, isActive, isPriority]);
|
}, [activationRank, isActive, isPriority]);
|
||||||
|
|
||||||
return (
|
return <div ref={containerRef} className="h-full">{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}</div>;
|
||||||
<div ref={containerRef} className="h-full">
|
|
||||||
{isActive ? renderWidget(type, config, onConfigChange) : <DeferredWidgetFallback />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardClient() {
|
export function DashboardClient() {
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const {
|
const { config, isHydrated, saveStatus, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
|
||||||
config,
|
useDashboardLayout();
|
||||||
isHydrated,
|
|
||||||
saveStatus,
|
|
||||||
addWidget,
|
|
||||||
removeWidget,
|
|
||||||
updateWidgetConfig,
|
|
||||||
onLayoutChange,
|
|
||||||
resetLayout,
|
|
||||||
} = useDashboardLayout();
|
|
||||||
|
|
||||||
// Measure grid container width so Responsive knows the column size.
|
// Measure grid container width so Responsive knows the column size.
|
||||||
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
|
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user