2 Commits

Author SHA1 Message Date
Hartmut cfce1f2a15 test(shared): narrow PasswordCheckResult before reading reason
CI / Architecture Guardrails (pull_request) Successful in 6m11s
CI / Assistant Split Regression (pull_request) Successful in 7m19s
CI / Lint (pull_request) Successful in 7m59s
CI / Typecheck (pull_request) Successful in 9m28s
CI / Build (pull_request) Successful in 6m53s
CI / E2E Tests (pull_request) Successful in 6m7s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m52s
CI / Release Images (pull_request) Has been skipped
CI / Unit Tests (pull_request) Successful in 8m30s
CI typecheck failed because the discriminated union returned by
checkPasswordPolicy only exposes `reason` on the `{ ok: false }` branch.
Guard each `.reason` assertion with `if (!result.ok)` so the test file
typechecks under exactOptionalPropertyTypes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 14:53:30 +02:00
Hartmut e01074926e security: reject common/weak passwords on every set-password path (#31)
CI / Architecture Guardrails (pull_request) Successful in 6m31s
CI / Typecheck (pull_request) Failing after 6m9s
CI / Build (pull_request) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 7m23s
CI / Lint (pull_request) Successful in 6m54s
CI / Unit Tests (pull_request) Successful in 9m28s
CI / Release Images (pull_request) Has been skipped
Adds a synchronous policy check that blocks (1) the curated >=12-char
common-password list (rockyou top, predictable seasonal, admin defaults),
(2) trivial patterns (single-char repeat, short-pattern repeat, keyboard
or numeric sequences), and (3) passwords containing the user's email
local-part or any name component. Wired into all five password-mutation
sites: first-admin setup, admin createUser/setUserPassword, invite
acceptance, and password-reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 14:09:38 +02:00
947 changed files with 17435 additions and 24792 deletions
+5 -5
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+25 -25
View File
@@ -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 &amp; Capacity Planning for 3D Production Studios</strong><br/> <strong>Resource &amp; 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
@@ -98,7 +98,7 @@ 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 |
@@ -173,7 +173,7 @@ 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 |
@@ -188,7 +188,7 @@ A full project estimation workflow:
### Monorepo Structure ### Monorepo Structure
``` ```
nexus/ capakraken/
| |
+-- apps/ +-- apps/
| +-- web/ Next.js 15 application (frontend + API routes) | +-- web/ Next.js 15 application (frontend + API routes)
@@ -264,7 +264,7 @@ 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` |
@@ -273,8 +273,8 @@ nexus/
### 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",
@@ -373,11 +373,11 @@ 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 |
--- ---
@@ -387,7 +387,7 @@ 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 |
@@ -395,7 +395,7 @@ When running with Docker Compose, the following services are available:
### 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 |
@@ -408,7 +408,7 @@ When running with Docker Compose, the following services are available:
### 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) |
@@ -422,14 +422,14 @@ When running with Docker Compose, the following services are available:
## 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)
@@ -455,7 +455,7 @@ 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 |
@@ -463,7 +463,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
| `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) |
@@ -474,7 +474,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
## 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 |
@@ -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
``` ```
+1 -1
View File
@@ -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
View File
@@ -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 });
+16 -22
View File
@@ -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");
+4 -4
View File
@@ -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 });
}); });
}); });
+5 -15
View File
@@ -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 () => {
+1 -1
View File
@@ -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)/);
+5 -4
View File
@@ -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(),
+2 -4
View File
@@ -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");
} }
}); });
+13 -29
View File
@@ -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");
+1 -1
View File
@@ -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 });
);
}); });
}); });
+16 -25
View File
@@ -10,25 +10,21 @@ 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();
+6 -20
View File
@@ -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());
+4 -10
View File
@@ -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();
+5 -9
View File
@@ -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 });
}); });
+10 -24
View File
@@ -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();
} }
}); });
+12 -17
View File
@@ -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 });
} }
}); });
+2 -3
View File
@@ -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,8 +21,7 @@ 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";
+5 -6
View File
@@ -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]"))
+2 -2
View File
@@ -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 });
+6 -10
View File
@@ -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 });
+3 -3
View File
@@ -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], {
+112 -239
View File
@@ -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;
@@ -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")
.count();
const projectBars = await page
.locator("[data-timeline-entry-type='project-bar']")
.count();
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count(); const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
const emptyStates = await page.getByText(/No projects in this time range/).count(); const emptyStates = await page.getByText(/No projects in this time range/).count();
return projectRows + projectBars + demandBars + emptyStates; return projectRows + projectBars + demandBars + emptyStates;
}, }, { timeout: 10_000 })
{ 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,8 +1427,7 @@ 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;
@@ -1448,9 +1439,7 @@ test.describe("Timeline", () => {
} }
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,8 +1451,7 @@ 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;
@@ -1475,9 +1463,7 @@ test.describe("Timeline", () => {
} }
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,8 +1505,7 @@ 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;
@@ -1529,9 +1517,7 @@ test.describe("Timeline", () => {
} }
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,8 +1538,7 @@ 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;
@@ -1565,9 +1550,7 @@ test.describe("Timeline", () => {
} }
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();
+11 -28
View File
@@ -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 -1
View File
@@ -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 [
+6 -6
View File
@@ -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: {
+9 -9
View File
@@ -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",
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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 -1
View File
@@ -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":
+10 -8
View File
@@ -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 -1
View File
@@ -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";
+3 -9
View File
@@ -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");
} }
+3 -7
View File
@@ -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 -1
View File
@@ -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";
+6 -3
View File
@@ -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";
+6 -6
View File
@@ -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";
+3 -3
View File
@@ -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 }> }) {
+2 -2
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>
+15 -3
View File
@@ -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 -1
View File
@@ -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,7 +4,7 @@ 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;
@@ -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,14 +85,11 @@ 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
(trpc.allocation.checkConflicts.useQuery as any)( const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
{ {
resourceId: debouncedResourceId, resourceId: debouncedResourceId,
startDate: conflictCheckStart, startDate: conflictCheckStart,
@@ -96,10 +98,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
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 { ) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
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,21 +434,13 @@ 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"
@@ -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,9 +118,7 @@ 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, resourceId: selectedResource.id,
resourceName: selectedResource.displayName, resourceName: selectedResource.displayName,
eid: selectedResource.eid, eid: selectedResource.eid,
@@ -134,8 +128,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
conflictDays: avail.conflictDays, conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent, coveragePercent: avail.coveragePercent,
estimatedCostCents, 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">&times;</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"
>
&times;
</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
@@ -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(
(key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides })); 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,7 +311,8 @@ 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);
@@ -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, enabled: mentionQuery !== null,
staleTime: 60_000, staleTime: 60_000,
}, });
);
const filteredUsers: MentionCandidate[] = const filteredUsers: MentionCandidate[] =
mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : []; mentionQuery !== null ? (usersQuery.data ?? []).slice(0, 8) : [];
@@ -66,7 +63,8 @@ export function CommentInput({
setMentionIndex(0); setMentionIndex(0);
}, [mentionQuery]); }, [mentionQuery]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const value = e.target.value;
const cursor = e.target.selectionStart ?? value.length; const cursor = e.target.selectionStart ?? value.length;
setBody(value); setBody(value);
@@ -81,7 +79,9 @@ export function CommentInput({
} 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>
@@ -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