19 Commits

Author SHA1 Message Date
Hartmut 12044f638e ci: retrigger — E2E webServer timeout on run #172 (QNAP runner flake)
CI / Typecheck (pull_request) Successful in 3m25s
CI / Architecture Guardrails (pull_request) Successful in 3m44s
CI / Lint (pull_request) Successful in 2m8s
CI / Assistant Split Regression (pull_request) Successful in 3m44s
CI / Unit Tests (pull_request) Successful in 7m29s
CI / Build (pull_request) Successful in 6m52s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 3m53s
CI / E2E Tests (pull_request) Successful in 20m24s
CI / Release Images (pull_request) Has been skipped
2026-05-22 09:34:10 +02:00
Hartmut 2383bcbdc0 fix(timeline): trigger scroll-to-today on isInitialLoading→false not totalCanvasWidth
CI / Architecture Guardrails (pull_request) Successful in 2m53s
CI / Typecheck (pull_request) Successful in 3m28s
CI / Assistant Split Regression (pull_request) Successful in 3m40s
CI / Lint (pull_request) Successful in 4m26s
CI / Unit Tests (pull_request) Successful in 8m36s
CI / Build (pull_request) Successful in 9m47s
CI / E2E Tests (pull_request) Failing after 14m2s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 16m53s
CI / Release Images (pull_request) Has been skipped
totalCanvasWidth is computed from viewStart/viewDays before data loads,
so the previous trigger fired during the loading spinner. scrollLeft
was clipped to 0 (no canvas in DOM yet) and the guard was set, blocking
the real scroll after data arrived. Using isInitialLoading as the dep
fires the effect exactly when the canvas enters the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:48:23 +02:00
Hartmut 0e9d6ec388 fix(timeline): wait for canvas width before scrolling to today
CI / Assistant Split Regression (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Typecheck (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Architecture Guardrails (pull_request) Has been cancelled
useLayoutEffect([]) fired before isInitialLoading resolved, so the
scroll container had no canvas yet — scrollLeft was clipped to 0.
Now the scroll-to-today fires on the first render where totalCanvasWidth
becomes non-zero. The cleanup effect resets the guard on unmount so
React Strict Mode's fake-unmount+remount also scrolls correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:45:09 +02:00
Hartmut 7285668c52 fix(timeline): use empty-deps useLayoutEffect for mount scroll to today
CI / Architecture Guardrails (pull_request) Successful in 4m53s
CI / Typecheck (pull_request) Successful in 4m55s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Build (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
The guard-ref approach broke in React Strict Mode (dev): the ref
persisted as `true` across the simulated remount, so the second
invocation skipped the scroll — leaving scrollLeft=0 (today-90
at the left edge, not today). An empty-deps useLayoutEffect runs
twice in Strict Mode but both executions fire against the same
initial `toLeft` and produce the correct result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:38:08 +02:00
Hartmut 944d36bdb2 fix(timeline): pre-load 90-day past buffer + scroll to today on mount
CI / Architecture Guardrails (pull_request) Successful in 5m6s
CI / Typecheck (pull_request) Successful in 7m31s
CI / Assistant Split Regression (pull_request) Successful in 6m45s
CI / Lint (pull_request) Successful in 6m19s
CI / Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
viewStart=today left no canvas to the left of scrollLeft=0, making
left-scroll physically impossible. Now viewStart defaults to today-90
so the canvas always has 90 days to scroll into, and a mount-time
useLayoutEffect positions the viewport with today at the left edge.

The Today button restores this view: scrolls in-range, or resets
viewStart and schedules a post-layout scroll if today has scrolled
out of the visible window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:15:37 +02:00
Hartmut 6ec512e302 test(cron): raise timeout for next/server cold-import on act runner
CI / Architecture Guardrails (pull_request) Successful in 4m22s
CI / Typecheck (pull_request) Successful in 6m48s
CI / Assistant Split Regression (pull_request) Successful in 7m49s
CI / Lint (pull_request) Successful in 7m59s
CI / E2E Tests (pull_request) Has been cancelled
CI / Fresh-Linux Docker Deploy (pull_request) Has been cancelled
CI / Release Images (pull_request) Has been cancelled
CI / Unit Tests (pull_request) Has been cancelled
CI / Build (pull_request) Has been cancelled
The test takes >5s on the QNAP act runner because dynamic import of
next/server has to transpile the module cold on first call. Raise the
per-test timeout to 15s to give it headroom without changing the test logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 08:06:56 +02:00
Hartmut 4a841d5acb feat(timeline): start at today and allow infinite scroll into the past
CI / Architecture Guardrails (pull_request) Successful in 17m31s
CI / Assistant Split Regression (pull_request) Successful in 9m42s
CI / Typecheck (pull_request) Successful in 20m48s
CI / Lint (pull_request) Successful in 8m6s
CI / Unit Tests (pull_request) Failing after 7m32s
CI / Build (pull_request) Successful in 9m12s
CI / E2E Tests (pull_request) Successful in 6m12s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m58s
CI / Release Images (pull_request) Has been skipped
Previously viewStart defaulted to today-30 and the scroll container had
no left-edge expansion logic, so users hit a hard wall when scrolling
left. This change:

- Sets viewStart default to today so the viewport opens with today at
  the left edge (URL ?startDate= override still respected).
- Adds left-edge auto-expansion in handleContainerScroll: when the user
  scrolls within 40 cells of the left boundary, 120 days are prepended
  and a useLayoutEffect applies the matching scrollLeft compensation in
  the same paint frame to prevent a visual jump.
- Floors backward navigation at 5 years (minDate) to prevent unbounded
  viewDays growth.
- Updates handleNavigateToday to match: resets to today rather than
  today-30.

Both resource view and project view use the same TimelineContext /
TimelineView, so both are fixed by this change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:16:34 +02:00
Hartmut 749a39097c ci: retrigger — runner flake on unit-tests step (run #163)
CI / Architecture Guardrails (pull_request) Successful in 4m9s
CI / Typecheck (pull_request) Successful in 5m41s
CI / Lint (pull_request) Successful in 5m47s
CI / Assistant Split Regression (pull_request) Successful in 6m8s
CI / Build (pull_request) Failing after 15m55s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Unit Tests (pull_request) Successful in 30m26s
CI / Release Images (pull_request) Failing after 10m48s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:05:16 +02:00
Hartmut a58b99a33a rename(cleanup): drop last capakraken strings from UI, scripts, schema, tests
CI / Architecture Guardrails (pull_request) Successful in 4m26s
CI / Assistant Split Regression (pull_request) Successful in 5m38s
CI / Lint (pull_request) Successful in 6m6s
CI / Typecheck (pull_request) Successful in 6m34s
CI / Build (pull_request) Successful in 4m13s
CI / Unit Tests (pull_request) Failing after 10m20s
CI / E2E Tests (pull_request) Successful in 5m28s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m14s
CI / Release Images (pull_request) Has been skipped
AppShell.tsx top-left brand → Nexus (desktop sidebar + mobile top-bar),
shell echo strings, prisma schema header, test fixture token, playwright
runtime DB URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:57:43 +02:00
Hartmut c5b58a5bdc fix(docs): update nginx-hardening.conf to nexus domain and log paths
Server block comment, access_log and error_log paths all updated from
capakraken.hartmut-noerenberg.com to nexus.hartmut-noerenberg.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:41:58 +02:00
Hartmut 52ddbe7377 fix(migrate): use relname not table_name in pg_stat_user_tables query
CI / Architecture Guardrails (push) Successful in 2m54s
CI / Typecheck (push) Successful in 2m56s
CI / Lint (push) Successful in 3m2s
CI / Assistant Split Regression (push) Successful in 4m49s
CI / Unit Tests (push) Successful in 6m26s
CI / Build (push) Successful in 6m36s
CI / E2E Tests (push) Successful in 5m26s
CI / Fresh-Linux Docker Deploy (push) Successful in 6m2s
CI / Release Images (push) Successful in 7m53s
pg_stat_user_tables uses relname, not table_name. The wrong column caused
the row-count verification step to abort with ERROR: column does not exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:11:57 +02:00
Hartmut 19aeb2ba04 rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62)
CI / Lint (push) Successful in 3m4s
CI / Typecheck (push) Successful in 3m6s
CI / Architecture Guardrails (push) Successful in 3m8s
CI / Assistant Split Regression (push) Successful in 3m48s
CI / Build (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 3): compose/DB/infra + stray code refs capakraken → nexus (#62)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 20:07:18 +02:00
Hartmut b41c1d2501 rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00
Hartmut d9a7ec0338 test(application): bump exceljs row/column-limit test timeouts to 60s
CI / Architecture Guardrails (push) Successful in 2m39s
CI / Lint (push) Successful in 7m11s
CI / Assistant Split Regression (push) Successful in 8m57s
CI / Typecheck (push) Successful in 12m1s
CI / Unit Tests (push) Successful in 10m18s
CI / Build (push) Successful in 9m29s
CI / E2E Tests (push) Successful in 5m52s
CI / Fresh-Linux Docker Deploy (push) Successful in 6m54s
CI / Release Images (push) Successful in 4m39s
Nightly Security / Dependency Audit (push) Failing after 1m44s
Run #115 on main timed out after 30s on the Gitea runner under
concurrent-job load (writing 10001 rows via ExcelJS addRow + writeFile
is CPU-bound and CI contention pushed it past the previous threshold).
Locally these tests complete in ~1s, so doubling the budget removes
the flake without masking real regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 14:09:10 +02:00
Hartmut 17471af7f8 security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51, PR #59)
CI / Architecture Guardrails (push) Successful in 3m38s
CI / Assistant Split Regression (push) Successful in 4m40s
CI / Lint (push) Successful in 5m17s
CI / Typecheck (push) Successful in 5m46s
CI / Build (push) Successful in 7m1s
CI / Unit Tests (push) Failing after 9m41s
CI / Release Images (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / E2E Tests (push) Has started running
Closes #51 (ESLint rule + conventions doc remain as follow-up).

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-04-18 13:53:28 +02:00
Hartmut f0251a654a ci: retrigger marker — rerun ci.yml for fe79810 (Build log was never persisted)
CI / Architecture Guardrails (push) Successful in 2m10s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 3m51s
CI / Assistant Split Regression (push) Successful in 6m9s
CI / Unit Tests (push) Successful in 8m53s
CI / Build (push) Successful in 7m32s
CI / E2E Tests (push) Successful in 7m2s
CI / Fresh-Linux Docker Deploy (push) Successful in 8m11s
CI / Release Images (push) Successful in 6m15s
Nightly Security / Dependency Audit (push) Successful in 1m13s
Previous run's Build job failed but Gitea's actions log store didn't retain
the output (dbfs reports the file missing), so we can't diagnose from here.
Rerun to either reproduce the failure with a persisted log, or green-ify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 19:15:00 +02:00
Hartmut fe79810a85 security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
Adds a one-time-use backup code set so users with a lost authenticator are not
locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and
redeemed under a WHERE-guarded delete so a concurrent replay race fails closed.

- New MfaBackupCode model + migration
- Issue 10 codes inside the enable transaction; show plaintext exactly once
- Sign-in page accepts TOTP or backup code, reporting remaining count
- regenerateBackupCodes tRPC mutation wipes + reissues atomically
- Unit coverage for generator, normalizer, verify, redeem, and race path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 18:47:18 +02:00
Hartmut 9dc1ffd3ad fix(ci): unblock build + unit-tests on main (#109)
CI / Architecture Guardrails (push) Successful in 4m17s
CI / Assistant Split Regression (push) Successful in 6m19s
CI / Lint (push) Successful in 8m18s
CI / Typecheck (push) Successful in 9m15s
CI / Unit Tests (push) Successful in 7m51s
CI / Build (push) Successful in 4m53s
CI / E2E Tests (push) Successful in 6m27s
CI / Fresh-Linux Docker Deploy (push) Successful in 8m2s
CI / Release Images (push) Successful in 7m26s
Two regressions surfaced after merging security/audit-2026-04-17:

1. **Build job** failed with `assertSecureRuntimeEnv` rejecting the CI
   `NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx`. The CI placeholder
   strings were added to `DISALLOWED_PRODUCTION_SECRETS` defensively, but
   that list is only consulted when `NODE_ENV=production` — exactly the
   mode `next build` runs in. The length + Shannon-entropy gates already
   reject genuinely weak prod secrets (the CI value scores ~3.68 vs the
   3.5 threshold), so removing the CI strings from the blocklist restores
   the build without weakening prod protection.

2. **Unit-tests job** failed with `(0 , brace_expansion_1.default) is not
   a function` from `minimatch@9` → `brace-expansion@5.0.5` (ESM-only)
   loaded via CJS `require`. The blanket override `"brace-expansion":
   "^5.0.5"` (added for CVE-2025-5889) was too broad. Switching to the
   targeted `"brace-expansion@<2.0.2": ">=2.0.2"` patches the CVE while
   leaving CJS consumers (test-exclude/glob/minimatch) on v2.

Drops the now-stale CI-placeholder unit test in `runtime-env.test.ts`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:30:05 +02:00
Hartmut 656c9329f7 Merge branch 'security/audit-2026-04-17'
CI / Architecture Guardrails (push) Successful in 3m11s
CI / Assistant Split Regression (push) Successful in 4m51s
CI / Lint (push) Successful in 6m1s
CI / Typecheck (push) Successful in 6m55s
CI / Unit Tests (push) Failing after 5m16s
CI / Build (push) Failing after 4m4s
CI / E2E Tests (push) Has been skipped
CI / Fresh-Linux Docker Deploy (push) Has been skipped
CI / Release Images (push) Has been skipped
Security audit 2026-04-17 — 20 commits hardening the application surface ahead of the Accenture CDP review.

Major changes:
- Auth: constant-time authorize, Unicode-aware prompt-injection guard, TOTP replay-race CAS, cookie/session hardening, E2E bypass fail-fast, login timing attack fix, AUTH_SECRET entropy enforcement, RBAC cache pub/sub, password policy alignment
- Authorization: default-deny /api middleware, scoped-caller completeness verification
- Input validation: JSONB bound, batchUpdateCustomFields whitelist, Zod .max() hardening, dispo workbook path allowlist, image polyglot validator
- AI: assistant chat payload cap, project-cover prompt injection guard, password redaction in audit DB entries, per-turn AssistantPrompt audit, Prisma error masking in AI-tool helpers
- Network: CSP tightening, SSRF guard IPv6 + DNS-rebind, blueprint validator ReDoS hardening
- Ops: Docker/Compose hardening, read-only AI DB proxy raw/tx escape-hatch block, audit writes awaited for durability

Resolves Gitea #38–#58 (security audit series).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:11:57 +02:00
980 changed files with 26250 additions and 17366 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"aed37e34-4be8-4788-b03a-7145d9b4b2ce","pid":3544538,"procStart":"34480817","acquiredAt":1779373227101}
+7 -7
View File
@@ -1,5 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# CapaKraken — environment variable reference
# Nexus — environment variable reference
#
# 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.
@@ -12,7 +12,7 @@
# 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.
# Must use https:// in production.
NEXTAUTH_URL=https://capakraken.example.com
NEXTAUTH_URL=https://nexus.example.com
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
# Generate one with: openssl rand -base64 32
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
# container this variable is overridden by docker-compose.yml (which routes
# to the postgres service name on the internal network).
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus
# ─── Redis ───────────────────────────────────────────────────────────────────
@@ -65,7 +65,7 @@ REDIS_PASSWORD=
# SMTP_PORT=587
# SMTP_USER=no-reply@example.com
# SMTP_PASSWORD=
# SMTP_FROM=CapaKraken <no-reply@example.com>
# SMTP_FROM=Nexus <no-reply@example.com>
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
@@ -74,8 +74,8 @@ REDIS_PASSWORD=
# Used as the password for the pgAdmin web UI (http://localhost:5050).
PGADMIN_PASSWORD=
# Email shown on the pgAdmin login screen (default: admin@capakraken.dev).
# PGADMIN_EMAIL=admin@capakraken.dev
# Email shown on the pgAdmin login screen (default: admin@nexus.dev).
# PGADMIN_EMAIL=admin@nexus.dev
# ─── Logging ─────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
# that any resolved path remains inside this directory; this prevents an
# admin (or compromised admin token) from pointing the parser at arbitrary
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
# DISPO_IMPORT_DIR=/var/lib/capakraken/imports
# DISPO_IMPORT_DIR=/var/lib/nexus/imports
# ─── Testing (never enable in production) ────────────────────────────────────
+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
Im capakraken-Repo → **Settings → Actions → Secrets** eintragen:
Im nexus-Repo → **Settings → Actions → Secrets** eintragen:
| Secret | Zweck |
| ----------------------- | -------------------------------------- |
+1 -1
View File
@@ -2,7 +2,7 @@
## Reporting a Vulnerability
If you discover a security vulnerability in CapaKraken, please report it responsibly.
If you discover a security vulnerability in Nexus, please report it responsibly.
**Do not** open a public GitHub issue for security vulnerabilities.
+44 -40
View File
@@ -1,6 +1,6 @@
name: CI
# Retrigger marker: b2d89ca (docker-deploy smoke retry)
# Retrigger marker: fe79810 (Build log lost — retrigger to re-observe)
on:
push:
branches: [main]
@@ -114,7 +114,7 @@ jobs:
run: pnpm db:generate
- name: Run assistant split regression
run: pnpm --filter @capakraken/api test:assistant-split
run: pnpm --filter @nexus/api test:assistant-split
# ──────────────────────────────────────────────
# Lint — ~20s, no services needed
@@ -159,11 +159,11 @@ jobs:
postgres:
image: postgres:16
env:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
POSTGRES_PASSWORD: capakraken_test
POSTGRES_DB: nexus_test
POSTGRES_USER: nexus
POSTGRES_PASSWORD: nexus_test
options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -175,7 +175,7 @@ jobs:
--health-timeout=5s
--health-retries=5
env:
DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test
REDIS_URL: redis://redis:6379
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
@@ -204,13 +204,13 @@ jobs:
- name: Run unit tests with coverage
run: |
pnpm --filter @capakraken/web test:unit -- --coverage
pnpm --filter @capakraken/engine exec vitest run --coverage
pnpm --filter @capakraken/staffing exec vitest run --coverage
pnpm --filter @capakraken/api exec vitest run --coverage
pnpm --filter @capakraken/application exec vitest run --coverage
pnpm --filter @capakraken/shared exec vitest run --coverage
pnpm --filter @capakraken/db test:unit
pnpm --filter @nexus/web test:unit -- --coverage
pnpm --filter @nexus/engine exec vitest run --coverage
pnpm --filter @nexus/staffing exec vitest run --coverage
pnpm --filter @nexus/api exec vitest run --coverage
pnpm --filter @nexus/application exec vitest run --coverage
pnpm --filter @nexus/shared exec vitest run --coverage
pnpm --filter @nexus/db test:unit
- name: Upload coverage reports
uses: actions/upload-artifact@v4
@@ -274,7 +274,7 @@ jobs:
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Build
run: pnpm --filter @capakraken/web exec next build
run: pnpm --filter @nexus/web exec next build
# ──────────────────────────────────────────────
# E2E — depends on build, needs PostgreSQL + Redis
@@ -291,11 +291,11 @@ jobs:
e2epg:
image: postgres:16
env:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
POSTGRES_PASSWORD: capakraken_test
POSTGRES_DB: nexus_test
POSTGRES_USER: nexus
POSTGRES_PASSWORD: nexus_test
options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test"
--health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
@@ -307,14 +307,14 @@ jobs:
--health-timeout=5s
--health-retries=5
env:
DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
# Playwright test-server.mjs requires an explicit test DB URL.
PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
# the expected target; default is "capakraken", CI uses capakraken_test.
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
# the expected target; default is "nexus", CI uses nexus_test.
NEXUS_EXPECTED_DB_NAME: nexus_test
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
REDIS_URL: redis://e2eredis:6379
PORT: 3100
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
@@ -364,18 +364,18 @@ jobs:
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm --filter @capakraken/web exec playwright install --with-deps chromium
run: pnpm --filter @nexus/web exec playwright install --with-deps chromium
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm --filter @capakraken/web exec playwright install-deps chromium
run: pnpm --filter @nexus/web exec playwright install-deps chromium
- name: Install psql (debug schema state)
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
- name: Push DB schema & seed
env:
PGPASSWORD: capakraken_test
PGPASSWORD: nexus_test
run: |
# Nuke any leftover schema state from a previous job that shared the
# postgres service container (act_runner reuses service volumes).
@@ -397,7 +397,7 @@ jobs:
IPS=$(getent hosts e2epg | awk '{print $1}')
PG_IP=""
for ip in $IPS; do
if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
PG_IP="$ip"
echo "Locked onto postgres at $PG_IP"
break
@@ -406,19 +406,19 @@ jobs:
fi
done
if [ -z "$PG_IP" ]; then
echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials"
exit 1
fi
PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test"
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "--- DROP SCHEMA ---"
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;"
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;"
echo "--- prisma db push ---"
DATABASE_URL="$PINNED_URL" pnpm --filter @capakraken/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
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 nexus -d nexus_test -v ON_ERROR_STOP=1 -At \
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
| tee /tmp/tables.txt
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
@@ -438,7 +438,7 @@ jobs:
# and restarts mid-run, producing cascading ECONNREFUSED failures
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
# is run locally / in a dedicated nightly job.
run: pnpm --filter @capakraken/web exec playwright test e2e/smoke.spec.ts
run: pnpm --filter @nexus/web exec playwright test e2e/smoke.spec.ts
- name: Upload Playwright report
uses: actions/upload-artifact@v4
@@ -468,8 +468,8 @@ jobs:
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
PGADMIN_PASSWORD=ci-pgadmin
# Must match the password baked into docker-compose.ci.yml's
# DATABASE_URL override (capakraken_dev).
POSTGRES_PASSWORD=capakraken_dev
# DATABASE_URL override (nexus_dev).
POSTGRES_PASSWORD=nexus_dev
EOF
- name: Tear down any stale stack & volumes
@@ -477,7 +477,11 @@ jobs:
# runs. A previous run's failed migration entry in _prisma_migrations
# causes P3009 on the next migrate deploy; wipe volumes for a truly
# fresh deploy test every time.
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
# Also tear down the legacy "capakraken" project (pre-Phase-3 rename)
# in case old containers are still holding host ports 5433/6380.
run: |
docker compose -p capakraken --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
docker compose --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
- name: Start infrastructure (postgres + redis)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
@@ -485,7 +489,7 @@ jobs:
- name: Wait for postgres
run: |
for i in $(seq 1 20); do
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break
sleep 3
done
@@ -576,7 +580,7 @@ jobs:
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/.prisma /app/scripts/node_modules/.prisma
node /app/scripts/setup-admin.mjs --email admin@capakraken.dev --name Admin --password admin123
node /app/scripts/setup-admin.mjs --email admin@nexus.dev --name Admin --password admin123
'
- name: Set up Node.js 20
+4 -4
View File
@@ -1,8 +1,8 @@
# CapaKraken
# Nexus
## Ziel
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.
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.
## Tech Stack
@@ -19,7 +19,7 @@ CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-P
## Monorepo-Struktur
```text
capakraken/
nexus/
├── apps/web
├── packages/shared
├── packages/db
@@ -41,7 +41,7 @@ capakraken/
## Quality Gates
- `pnpm test:unit`
- `pnpm --filter @capakraken/web exec tsc --noEmit`
- `pnpm --filter @nexus/web exec tsc --noEmit`
- `pnpm lint`
## Dokumente
+1 -1
View File
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
COPY . .
# Generate Prisma client
RUN pnpm --filter @capakraken/db db:generate
RUN pnpm --filter @nexus/db db:generate
EXPOSE 3100
+3 -3
View File
@@ -39,7 +39,7 @@ COPY --from=deps /app/ ./
COPY . .
# Generate Prisma client
RUN pnpm --filter @capakraken/db db:generate
RUN pnpm --filter @nexus/db db:generate
# Build the Next.js application
ENV NEXT_TELEMETRY_DISABLED=1
@@ -63,7 +63,7 @@ RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
AUTH_SECRET="$AUTH_SECRET" \
DATABASE_URL="$DATABASE_URL" \
REDIS_URL="$REDIS_URL" \
pnpm --filter @capakraken/web build
pnpm --filter @nexus/web build
# ============================================================
# Stage 3: Migration runner
@@ -72,7 +72,7 @@ FROM builder AS migrator
ENV NODE_ENV=production
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"]
CMD ["pnpm", "--filter", "@nexus/db", "db:migrate:deploy"]
# ============================================================
# Stage 4: Production runtime
+69 -18
View File
@@ -1,6 +1,7 @@
# CapaKraken Projekt-Learnings
# Nexus Projekt-Learnings
## Format
**Datum | Kategorie | Problem → Lösung**
---
@@ -12,11 +13,13 @@
**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:**
- `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.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
**Middleware wrapping:**
```ts
import { auth } from "./server/auth-edge.js";
export default auth(function middleware(request) {
@@ -28,17 +31,19 @@ export default auth(function middleware(request) {
```
**Three-layer defence:**
1. Middleware — server-side redirect before page renders
2. `SessionGuard` client component — `useSession()``router.replace()` on SPA navigation
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
**Test mock pattern for middleware tests:**
```ts
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.
---
@@ -50,12 +55,14 @@ Needed because `vi.resetModules()` inside the helper function doesn't re-apply t
**Repo path:** `Hartmut/plANARCHY`
Usage example (list open issues):
```bash
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"
```
Close an issue with a comment:
```bash
TOKEN=$(cat ~/.gitea-token)
REPO="Hartmut/plANARCHY"
@@ -75,18 +82,22 @@ 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.
**Solution:** Always restart the app container after Prisma schema changes:
```
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.
**Rule:** Prisma schema change checklist:
1. Edit `packages/db/prisma/schema.prisma`
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 capakraken-postgres-1 psql -U capakraken -d capakraken < 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`
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
### 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.
- `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)`.
@@ -95,24 +106,28 @@ 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.
### 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.
- 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).
### 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`.
- 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
- 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/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application`
- `packages/application` depends on `@nexus/db`, `@nexus/engine`, `@nexus/shared`; `packages/api` depends on `@nexus/application`
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
- `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
- `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
- 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.
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
@@ -120,12 +135,14 @@ 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.
### 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.
- 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 `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`.
- Shared estimate enums/types/schemas now live in `@nexus/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@nexus/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.
### 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.
- 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.
@@ -134,45 +151,55 @@ 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.
### 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.
- `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.
- `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
- `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`.
- `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.
- `@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).
- `@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).
- `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
**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:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
### 2026-03-09 | Performance | recomputeValueScores sequential updates
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
**Fix:** Use `ctx.dbUser?.id` directly.
### 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.
### 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.
### 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.
### 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.
**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.
@@ -180,6 +207,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 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.
**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.
@@ -187,6 +215,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
**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`).
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
@@ -194,19 +223,22 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
**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: {...} } : {}) })`.
---
### 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.
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben.
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/nexus`) verschieben.
**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
**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.
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
@@ -214,6 +246,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
@@ -221,6 +254,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 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.
**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.
@@ -228,8 +262,10 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 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:
- `project as unknown as Project`
- `form.orderType as unknown as OrderType`
- `resource.skills as unknown as SkillEntry[]`
@@ -237,6 +273,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
---
### 2026-02-xx | Architektur | SSE statt WebSockets
**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.
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
@@ -247,20 +284,21 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen schwer wartbar, kaum testbar.
**Lösung:** Schrittweise Extraktion:
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
2. Heatmap-Utilities → `heatmapUtils.ts`
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
4. Header-JSX → `TimelineHeader.tsx`
5. Toolbar-JSX → `TimelineToolbar.tsx`
**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.
**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.
---
### 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.
**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.
**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.
**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.
@@ -340,10 +378,12 @@ 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.
**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:
```typescript
const adminHash = await hash("admin123");
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.
---
@@ -383,18 +423,20 @@ 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.
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
**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.
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.
**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.
**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.
---
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
**Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
**Kontext:** Nexus hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
**Lösung:** Zweigeteiltes System:
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.
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
@@ -421,6 +463,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
---
## Offene Fragen
- [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] P7.2 Touch-Support → umgesetzt
@@ -430,6 +473,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
---
### 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.
**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.
@@ -439,14 +483,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
---
### 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.
**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`).
**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
**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:**
- **`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).
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
@@ -457,15 +504,17 @@ 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.
### 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.
**Lösung:**
- **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.
- **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.
- **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.
**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
@@ -474,11 +523,13 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
**Decision: No code is written until the product decision is made.**
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.
- **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 CapaKraken exposes a public API. Over-engineered for an internal tool with no current integration story.
- **Short-lived API tokens (OAuth-style):** Suitable if Nexus exposes a public API. Over-engineered for an internal tool with no current integration story.
**Engineering guidance for when the decision is made:**
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.
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
+98 -98
View File
@@ -1,8 +1,8 @@
<p align="center">
<img src="docs/screenshots/dashboard-dark.jpeg" alt="CapaKraken Dashboard" width="100%" />
<img src="docs/screenshots/dashboard-dark.jpeg" alt="Nexus Dashboard" width="100%" />
</p>
<h1 align="center">CapaKraken</h1>
<h1 align="center">Nexus</h1>
<p align="center">
<strong>Resource &amp; Capacity Planning for 3D Production Studios</strong><br/>
@@ -25,7 +25,7 @@
## About
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.
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.
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%" />
</p>
The timeline is the centerpiece of CapaKraken. It provides a visual, interactive view of all resource allocations across projects.
The timeline is the centerpiece of Nexus. 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
- **Project View** -- flip the perspective to see all resources assigned to each project
@@ -97,18 +97,18 @@ A structured list view of all allocations with:
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
| Widget | Description |
|--------|-------------|
| **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 |
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
| **Project Overview** | All projects with cost, person days, and status badges |
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
| **Budget Forecast** | Budget burn rate and projected cost per active project |
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
| Widget | Description |
| -------------------------- | --------------------------------------------------------------------------------- |
| **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 |
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
| **Project Overview** | All projects with cost, person days, and status badges |
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
| **Budget Forecast** | Budget burn rate and projected cost per active project |
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
@@ -172,23 +172,23 @@ A full project estimation workflow:
## Tech Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **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 |
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
| Layer | Technology | Purpose |
| -------------------- | --------------------------------- | -------------------------------------------------------- |
| **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 |
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
### Monorepo Structure
```
capakraken/
nexus/
|
+-- apps/
| +-- web/ Next.js 15 application (frontend + API routes)
@@ -263,18 +263,18 @@ capakraken/
### Prerequisites
| Requirement | Minimum Version | Check |
|-------------|----------------|-------|
| **Node.js** | 20.x | `node --version` |
| **pnpm** | 9.x | `pnpm --version` |
| **Docker** | 24+ | `docker --version` |
| **Docker Compose** | v2 | `docker compose version` |
| Requirement | Minimum Version | Check |
| ------------------ | --------------- | ------------------------ |
| **Node.js** | 20.x | `node --version` |
| **pnpm** | 9.x | `pnpm --version` |
| **Docker** | 24+ | `docker --version` |
| **Docker Compose** | v2 | `docker compose version` |
### 1. Clone and configure
```bash
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git capakraken
cd capakraken
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git nexus
cd nexus
```
Create your environment file:
@@ -315,13 +315,13 @@ This single command will:
You'll see output like:
```
Starting CapaKraken...
Starting Nexus...
Starting PostgreSQL + Redis...
Waiting for PostgreSQL...
Starting app container on port 3100...
Waiting for server (up to 90s)...
CapaKraken is running!
Nexus is running!
{
"status": "ok",
"database": "connected",
@@ -372,13 +372,13 @@ This populates the database with sample clients, projects, resources, allocation
When running with Docker Compose, the following services are available:
| Service | URL | Purpose |
|---------|-----|---------|
| **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) |
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `capakraken`, db: `capakraken`) |
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
| Service | URL | Purpose |
| -------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| **Nexus 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) |
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `nexus`, db: `nexus`) |
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
---
@@ -386,50 +386,50 @@ When running with Docker Compose, the following services are available:
### Application Lifecycle
| Command | Description |
|---------|-------------|
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
| `bash scripts/stop.sh` | Stop all services gracefully |
| `bash scripts/restart.sh` | Full stop + start cycle |
| Command | Description |
| ------------------------- | ------------------------------------------- |
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
| `bash scripts/stop.sh` | Stop all services gracefully |
| `bash scripts/restart.sh` | Full stop + start cycle |
### Development
| Command | Description |
|---------|-------------|
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
| `pnpm build` | Production build (standalone output) |
| `pnpm lint` | Run ESLint across all packages |
| `pnpm format` | Format all files with Prettier |
| `pnpm test:unit` | Run unit tests via Vitest |
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
| `pnpm typecheck` | TypeScript type checking across all packages |
| Command | Description |
| ------------------------- | -------------------------------------------------------- |
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
| `pnpm build` | Production build (standalone output) |
| `pnpm lint` | Run ESLint across all packages |
| `pnpm format` | Format all files with Prettier |
| `pnpm test:unit` | Run unit tests via Vitest |
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
| `pnpm typecheck` | TypeScript type checking across all packages |
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
### Database
| Command | Description |
|---------|-------------|
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
| `pnpm db:migrate` | Create and apply new migrations |
| `pnpm db:push` | Push schema changes directly (no migration file) |
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
| `pnpm db:seed` | Seed the database with demo data |
| `pnpm db:doctor` | Run health checks on database state |
| `pnpm db:seed:export` | Export current DB state as a seed file |
| `pnpm db:seed:import` | Import a previously exported seed file |
| Command | Description |
| --------------------- | ------------------------------------------------ |
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
| `pnpm db:migrate` | Create and apply new migrations |
| `pnpm db:push` | Push schema changes directly (no migration file) |
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
| `pnpm db:seed` | Seed the database with demo data |
| `pnpm db:doctor` | Run health checks on database state |
| `pnpm db:seed:export` | Export current DB state as a seed file |
| `pnpm db:seed:import` | Import a previously exported seed file |
---
## Production Deployment
CapaKraken ships with a production-ready Docker Compose stack and deployment automation.
Nexus ships with a production-ready Docker Compose stack and deployment automation.
### Quick Deploy
```bash
# Configure required secrets
export APP_IMAGE=ghcr.io/your-org/capakraken-app:latest
export MIGRATOR_IMAGE=ghcr.io/your-org/capakraken-migrator:latest
export APP_IMAGE=ghcr.io/your-org/nexus-app:latest
export MIGRATOR_IMAGE=ghcr.io/your-org/nexus-migrator:latest
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
export REDIS_PASSWORD=$(openssl rand -hex 32)
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
@@ -454,35 +454,35 @@ bash tooling/deploy/deploy-compose.sh production
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_FROM` | No | `noreply@capakraken.dev` | Sender address for outgoing emails |
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
| Variable | Required | Default | Description |
| ---------------------- | -------- | -------------------- | ----------------------------------------------- |
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_FROM` | No | `noreply@nexus.dev` | Sender address for outgoing emails |
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
---
## Design Principles
| Principle | Implementation |
|-----------|---------------|
| **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 |
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
| Principle | Implementation |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **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 |
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
---
@@ -494,7 +494,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
4. Run quality gates before submitting:
```bash
pnpm test:unit
pnpm --filter @capakraken/web exec tsc --noEmit
pnpm --filter @nexus/web exec tsc --noEmit
pnpm lint
pnpm check:architecture
```
+1 -1
View File
@@ -3,7 +3,7 @@ import { test, expect } from "./a11y-fixture.js";
test.describe("Accessibility (axe-core)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
+17 -14
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Admin Pages", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -12,39 +12,42 @@ test.describe("Admin Pages", () => {
test("settings page loads", async ({ page }) => {
await page.goto("/admin/settings");
await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({
timeout: 10000,
});
});
test("users page loads with user list", async ({ page }) => {
await page.goto("/admin/users");
await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({
timeout: 10000,
});
// Should show a table with at least the admin user
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=admin@capakraken.dev")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=admin@nexus.dev")).toBeVisible({ timeout: 10000 });
});
test("roles page loads", async ({ page }) => {
await page.goto("/roles");
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
await expect(
page.locator("table").or(page.locator("text=No roles")),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("table").or(page.locator("text=No roles"))).toBeVisible({
timeout: 10000,
});
});
test("blueprints page loads", async ({ page }) => {
await page.goto("/admin/blueprints");
await page.waitForLoadState("networkidle");
await expect(
page.locator("h1").filter({ hasText: /Blueprints/i }),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1").filter({ hasText: /Blueprints/i })).toBeVisible({
timeout: 10000,
});
// Should show blueprint cards or list from seed data
await expect(
page.locator("table")
page
.locator("table")
.or(page.locator("text=3D Content Production"))
.or(page.locator("text=No blueprints")),
).toBeVisible({ timeout: 10000 });
+22 -16
View File
@@ -40,24 +40,28 @@ async function signIn(page: Page, email: string, password: string) {
test.describe("Allocations", () => {
test.beforeEach(async ({ page }) => {
await freezeBrowserTime(page);
await signIn(page, "admin@capakraken.dev", "admin123");
await signIn(page, "admin@nexus.dev", "admin123");
await page.goto("/allocations");
});
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
).toBeVisible({ timeout: 10000 });
await expect(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-empty-state")).toHaveCount(0);
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
});
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
test("explicitly restrictive filters show a visible empty state and can be reset", async ({
page,
}) => {
await page.waitForLoadState("networkidle");
const projectFilter = page.getByPlaceholder("Filter by project…");
@@ -83,21 +87,23 @@ test.describe("Allocations", () => {
await expect(newBtn).toBeVisible({ timeout: 10000 });
await newBtn.click();
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
await expect(
page.getByRole("heading", { name: /New (Assignment|Open Demand)/i }),
).toBeVisible();
await page.keyboard.press("Escape");
});
test("filter by status works", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Look for status filter chips or dropdown
const statusFilter = page.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i }).first();
const statusFilter = page
.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i })
.first();
if ((await statusFilter.count()) > 0) {
await statusFilter.click();
await page.waitForTimeout(300);
// 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();
}
});
@@ -108,17 +114,17 @@ test.describe("Allocations", () => {
await colToggle.click();
await page.waitForTimeout(300);
// 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");
}
});
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
test("viewer sees a visible access error instead of an empty allocations page", async ({
browser,
}) => {
const page = await browser.newPage();
await freezeBrowserTime(page);
await signIn(page, "viewer@capakraken.dev", "viewer123");
await signIn(page, "viewer@nexus.dev", "viewer123");
await page.goto("/allocations");
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) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -71,8 +71,8 @@ test.describe("Analytics / Insights", () => {
test("insights page loads without errors", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Page should render some heading or content area — not a hard 404
await expect(
page.locator("h1").or(page.locator("main")).first(),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1").or(page.locator("main")).first()).toBeVisible({
timeout: 10000,
});
});
});
+16 -6
View File
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ADMIN_EMAIL = "admin@capakraken.dev";
const ADMIN_EMAIL = "admin@nexus.dev";
const ADMIN_PASSWORD = "admin123";
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((conversationId) => {
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
}, CURRENT_CONVERSATION_ID);
runDb(`
@@ -159,7 +159,9 @@ test.describe("Assistant approvals", () => {
`);
});
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
test("renders the pending approval inbox and handles cross-conversation actions", async ({
page,
}) => {
const suffix = Date.now();
const currentClientName = `E2E Approval Client Current ${suffix}`;
const otherClientName = `E2E Approval Client Other ${suffix}`;
@@ -210,14 +212,22 @@ test.describe("Assistant approvals", () => {
await expect(page.getByText(currentApproval.summary)).toBeVisible();
await expect(page.getByText(otherApproval.summary)).toBeVisible();
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
const currentCard = page
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]')
.first();
const otherCard = page
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]')
.first();
await expect(currentCard).toContainText("This chat");
await expect(otherCard).toContainText("Other chat");
await otherCard.getByTestId("assistant-approval-cancel").click();
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
await expect(
page.locator(
`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`,
),
).toHaveCount(0);
await expect
.poll(async () => {
+1 -1
View File
@@ -8,7 +8,7 @@ test.describe("Authentication", () => {
test("admin can sign in", async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
+4 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,9 +16,7 @@ test.describe("Bench Board", () => {
test("bench board page loads with heading", async ({ page }) => {
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 }) => {
@@ -32,7 +30,8 @@ test.describe("Bench Board", () => {
test("shows bench results or no-resources empty state", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("table")
page
.locator("table")
.or(page.locator("text=No resources on bench"))
.or(page.locator("text=No results"))
.first(),
+4 -2
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -31,7 +31,9 @@ test.describe("Dashboard", () => {
const addBtn = page.locator("button", { hasText: /Add Widget/i });
if ((await addBtn.count()) > 0) {
await addBtn.click();
await expect(page.locator("text=Add Widget").or(page.locator("text=Available Widgets"))).toBeVisible();
await expect(
page.locator("text=Add Widget").or(page.locator("text=Available Widgets")),
).toBeVisible();
await page.keyboard.press("Escape");
}
});
+4 -4
View File
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
// Error message visible
await expect(
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
).toBeVisible({ timeout: 5000 });
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
timeout: 5000,
});
});
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
// At least one user row should be visible
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
timeout: 10000,
});
await expect(page.locator("text=No users found")).toHaveCount(0);
@@ -12,7 +12,7 @@
* - Creates a temporary test user via tRPC (admin session) for isolation.
* - Cleans up the test user in afterAll.
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
type TrpcResult = {
result?: { data?: unknown };
error?: { data?: { code?: string }; message?: string };
};
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcMutation(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
);
}
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcQuery(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
// Default layout should show at least the stat-cards widget
// (from createDefaultDashboardLayout in useDashboardLayout)
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
await expect(
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
).toBeVisible({
timeout: 8000,
});
});
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page);
// Open modal
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Verify modal is open
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
timeout: 5000,
});
// Verify widget entries are visible in the modal
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
const widgetButtons = page.locator(
'[role="dialog"] button, .fixed button[type="button"]',
).filter({ hasText: /./ });
const widgetButtons = page
.locator('[role="dialog"] button, .fixed button[type="button"]')
.filter({ hasText: /./ });
// Count items in the grid (the ×-close button is excluded by checking for icon content)
const modalContent = page.locator(".fixed.inset-0 .grid");
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
const initialCount = await page.locator(".react-grid-item").count();
// Open modal and add "Resource Table" widget
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
await page
.locator(".fixed.inset-0 button")
.filter({ hasText: /resource table/i })
.click();
// Modal should close after adding
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page);
// Add a recognizable widget
await page.getByRole("button", { name: /add widget/i }).first().click();
await page
.getByRole("button", { name: /add widget/i })
.first()
.click();
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
await page
.locator(".fixed.inset-0 button")
.filter({ hasText: /project overview/i })
.click();
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
const countAfterAdd = await page.locator(".react-grid-item").count();
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
// Read the admin's localStorage key to verify it is user-scoped
const adminUserId = await adminPage.evaluate(async () => {
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
credentials: "include",
});
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
const res = await fetch(
"/api/trpc/user.me?batch=1&input=" +
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
{
credentials: "include",
},
);
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
return body[0]?.result?.data?.json?.id ?? null;
});
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
if (adminUserId) {
const storageKey = await adminPage.evaluate((userId) => {
// Check both old (unscoped) and new (user-scoped) key formats
const oldKey = "capakraken_dashboard_v1";
const newKey = `capakraken_dashboard_v1_${userId}`;
const oldKey = "nexus_dashboard_v1";
const newKey = `nexus_dashboard_v1_${userId}`;
const oldValue = localStorage.getItem(oldKey);
const newValue = localStorage.getItem(newKey);
return { oldKey: oldValue !== null, newKey: newValue !== null };
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
// Inject the admin's storage key to simulate same browser
await newUserPage.evaluate(
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
({ key, value }) => {
localStorage.setItem(key, value ?? "");
},
{
key: `nexus_dashboard_v1_${adminUserId}`,
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
},
);
// Log in as test user
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
const gridItems = await newUserPage.locator(".react-grid-item").count();
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
// The key check: the test user's Add Widget button should still work
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
await newUserPage
.getByRole("button", { name: /add widget/i })
.first()
.click();
// Modal must show widgets to choose from
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
+3 -3
View File
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
password: "Dev123456!",
};
const DB_CONTAINER = "capakraken-postgres-1";
const DB_USER = "capakraken";
const DB_NAME = "capakraken";
const DB_CONTAINER = "nexus-postgres-1";
const DB_USER = "nexus";
const DB_NAME = "nexus";
function psqlExec(sql: string): string {
return execSync(
+15 -12
View File
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
await page.goto("/dashboard"); // land on any authenticated page for cookie context
await page.evaluate(async () => {
const csrfRes = await fetch("/api/auth/csrf");
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
await fetch("/api/auth/signout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -62,11 +62,9 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
const enc = (encoding ?? "").toLowerCase().trim();
if (enc === "quoted-printable") {
return body
.replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
String.fromCharCode(parseInt(hex, 16)),
);
.replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
}
if (enc === "base64") {
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
*/
export async function getLatestEmailTo(
address: string,
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
{
timeoutMs = 10_000,
pollIntervalMs = 500,
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<{ subject: string; body: string; html: string }> {
const deadline = Date.now() + timeoutMs;
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
pathPrefix: string,
): string {
const text = email.html || email.body;
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
const match = text.match(
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
);
if (!match?.[0]) {
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
}
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
execSync(
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
{ input: sql, encoding: "utf8" },
);
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
input: sql,
encoding: "utf8",
});
}
// ── tRPC helpers ───────────────────────────────────────────────────────────────
+5 -3
View File
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
});
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
// Step 1: Navigate to admin users page
await page.goto("/admin/users");
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
// Step 2: Open invite modal
await page.click('button:has-text("Invite User")');
// Wait for the modal heading — AnimatedModal does not use role="dialog"
await page.waitForSelector('text=Invite User', { state: "visible" });
await page.waitForSelector("text=Invite User", { state: "visible" });
// Step 3: Fill in invite form
await page.fill('input[type="email"]', testEmail);
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
await page.click('button:has-text("Send Invite")');
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
timeout: 10_000,
});
// Step 6: Read invite email from Mailhog
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
+29 -13
View File
@@ -21,9 +21,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
type TrpcResult = {
result?: { data?: unknown };
error?: { data?: { code?: string }; message?: string };
};
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcMutation(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -39,7 +46,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
);
}
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
async function trpcQuery(
page: Page,
procedure: string,
input: unknown = null,
): Promise<TrpcResult> {
return page.evaluate(
async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -60,7 +71,7 @@ async function enableMfaForSession(page: Page): Promise<TOTP> {
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
const totp = new TOTP({
issuer: "CapaKraken",
issuer: "Nexus",
algorithm: "SHA1",
digits: 6,
period: 30,
@@ -92,7 +103,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
test.afterEach(async ({ page }) => {
// Clean up: disable MFA if a test enabled it
if (totp) {
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */});
await disableMfaForSession(page).catch(() => {
/* already disabled or admin override */
});
totp = null;
}
});
@@ -106,7 +119,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
expect(data?.secret).toBeTruthy();
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
expect(data?.uri).toContain("CapaKraken");
expect(data?.uri).toContain("Nexus");
});
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
@@ -137,9 +150,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
await page.waitForLoadState("networkidle");
// Click the enable/setup button if MFA is not yet enabled
const setupBtn = page.getByRole("button", { name: /set up/i }).or(
page.getByRole("button", { name: /enable.*mfa/i }),
);
const setupBtn = page
.getByRole("button", { name: /set up/i })
.or(page.getByRole("button", { name: /enable.*mfa/i }));
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await setupBtn.click();
@@ -233,9 +246,10 @@ test.describe("MFA — login flow", () => {
// Should show error and remain on TOTP step
await expect(
page.getByText(/invalid.*code|incorrect.*token|try again/i).or(
page.locator("[data-error]"),
).first(),
page
.getByText(/invalid.*code|incorrect.*token|try again/i)
.or(page.locator("[data-error]"))
.first(),
).toBeVisible({ timeout: 5000 });
// Should NOT have navigated away
@@ -248,7 +262,9 @@ test.describe("MFA — login flow", () => {
test.describe("MFA — users without MFA enabled", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => {
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
page,
}) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@planarchy.dev");
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)
*
* Run:
* pnpm --filter @capakraken/web exec playwright test \
* pnpm --filter @nexus/web exec playwright test \
* --config playwright.dev.config.ts \
* e2e/dev-system/nav-smoke.spec.ts
*/
@@ -27,10 +27,10 @@ test.describe("RBAC — admin routes (admin session)", () => {
await page.waitForLoadState("networkidle");
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
// Seed users have planarchy.dev or capakraken.dev email domains
await expect(
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(),
).toBeVisible({ timeout: 10000 });
// Seed users have planarchy.dev or nexus.dev email domains
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
timeout: 10000,
});
});
test("admin can access /admin/system-roles without errors", async ({ page }) => {
@@ -99,9 +99,10 @@ test.describe("RBAC — allocations permitted for admin", () => {
await page.goto("/allocations");
await page.waitForLoadState("networkidle");
await expect(
page.locator("text=/do not have permission to view allocations/i"),
).toHaveCount(0, { timeout: 8000 });
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
0,
{ timeout: 8000 },
);
});
});
@@ -112,9 +113,10 @@ test.describe("RBAC — allocations permitted for manager", () => {
await page.goto("/allocations");
await page.waitForLoadState("networkidle");
await expect(
page.locator("text=/do not have permission to view allocations/i"),
).toHaveCount(0, { timeout: 8000 });
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
0,
{ timeout: 8000 },
);
});
});
+26 -17
View File
@@ -10,22 +10,26 @@ async function signIn(page: Page, email: string, password: string) {
test.describe("Estimates", () => {
test.beforeEach(async ({ page }) => {
await signIn(page, "admin@capakraken.dev", "admin123");
await signIn(page, "admin@nexus.dev", "admin123");
await page.goto("/estimates");
});
test("estimate list loads", async ({ page }) => {
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(
page.getByRole("heading", { name: /estimate workspace/i }),
).toBeVisible({ timeout: 10000 });
await expect(
page.getByPlaceholder("Search by estimate or opportunity"),
).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."),
),
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 });
});
@@ -44,8 +48,13 @@ test.describe("Estimates", () => {
await page.locator("button", { hasText: /New Estimate/i }).click();
// Step 1: Setup — fill a name
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({ timeout: 5000 });
const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({
timeout: 5000,
});
const nameInput = page
.locator('input[placeholder*="name"]')
.or(page.locator('input[name="name"]'))
.first();
if ((await nameInput.count()) > 0) {
await nameInput.fill(`E2E Estimate ${Date.now()}`);
}
@@ -90,9 +99,7 @@ test.describe("Estimates", () => {
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
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 }) => {
@@ -103,12 +110,14 @@ test.describe("Estimates", () => {
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
const page = await browser.newPage();
await signIn(page, "viewer@capakraken.dev", "viewer123");
await signIn(page, "viewer@nexus.dev", "viewer123");
await page.goto("/estimates/missing-estimate");
await page.waitForLoadState("networkidle");
await expect(
page.locator("text=Your role can access the estimate list, but not the detailed financial workspace."),
page.locator(
"text=Your role can access the estimate list, but not the detailed financial workspace.",
),
).toBeVisible({ timeout: 10000 });
await page.close();
+20 -6
View File
@@ -2,14 +2,16 @@ import { expect, test, type Page } from "@playwright/test";
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
}
test.describe("Holiday Calendar Editor", () => {
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({
page,
}) => {
const suffix = Date.now().toString();
const calendarName = `E2E City Calendar ${suffix}`;
const holidayName = `E2E Local Holiday ${suffix}`;
@@ -21,11 +23,18 @@ test.describe("Holiday Calendar Editor", () => {
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
await page
.getByTestId("holiday-calendar-country-select")
.selectOption({ label: "Germany (DE)" });
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
await page.getByTestId("holiday-calendar-create-button").click();
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
await expect(
page
.getByTestId(/holiday-calendar-row-/)
.filter({ hasText: calendarName })
.first(),
).toBeVisible();
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
@@ -44,10 +53,15 @@ test.describe("Holiday Calendar Editor", () => {
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
await page.getByTestId("holiday-entry-create-button").click();
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
await expect(
page.getByText("A holiday entry for this calendar and date already exists"),
).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByTestId(/holiday-entry-delete-/).first().click();
await page
.getByTestId(/holiday-entry-delete-/)
.first()
.click();
await expect(page.getByText(holidayName).first()).not.toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
+10 -4
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Navigation", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -28,7 +28,7 @@ test.describe("Navigation", () => {
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
// Complements the click-based test above with a direct-navigation check
// covering every sidebar destination. Uses admin@capakraken.dev (ADMIN role).
// covering every sidebar destination. Uses admin@nexus.dev (ADMIN role).
const routes = [
// Already covered by click test but included for completeness
"/dashboard",
@@ -79,7 +79,10 @@ test.describe("Navigation", () => {
}
// Expand again — the button should still be visible as an icon
const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last();
const expandBtn = page
.locator("nav button")
.filter({ has: page.locator("svg") })
.last();
await expandBtn.click();
await page.waitForTimeout(300);
const boxExpanded = await nav.boundingBox();
@@ -113,7 +116,10 @@ test.describe("Navigation", () => {
await page.waitForLoadState("networkidle");
// The hamburger button should be visible on mobile
const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first();
const hamburgerBtn = page
.locator("button")
.filter({ has: page.locator("svg") })
.first();
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
await hamburgerBtn.click();
+9 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -74,9 +74,9 @@ test.describe("Project Detail Page", () => {
await page.waitForLoadState("networkidle");
// BudgetStatusCard renders budget-related content
await expect(
page.locator("text=Budget").or(page.locator("text=budget")).first(),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=Budget").or(page.locator("text=budget")).first()).toBeVisible({
timeout: 10000,
});
});
test("unknown project id shows not-found state", async ({ page }) => {
@@ -85,7 +85,11 @@ test.describe("Project Detail Page", () => {
// Server-side notFound() triggers the Next.js 404 page
await expect(
page.locator("text=404").or(page.locator("text=Not Found")).or(page.locator("text=not found")).first(),
page
.locator("text=404")
.or(page.locator("text=Not Found"))
.or(page.locator("text=not found"))
.first(),
).toBeVisible({ timeout: 10000 });
});
+24 -10
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Projects", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@capakraken.dev");
await page.fill('input[type="email"]', "manager@nexus.dev");
await page.fill('input[type="password"]', "manager123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/resources/);
@@ -26,9 +26,16 @@ test.describe("Projects", () => {
// Step 1: Blueprint selection
await expect(page.locator("text=Select Blueprint")).toBeVisible();
// Select the first available blueprint
const blueprintCard = page.locator("[data-blueprint-id]").first()
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first());
if (await blueprintCard.count() > 0) {
const blueprintCard = page
.locator("[data-blueprint-id]")
.first()
.or(
page
.locator("button")
.filter({ hasText: /Blueprint|Production/ })
.first(),
);
if ((await blueprintCard.count()) > 0) {
await blueprintCard.click();
} else {
// Click next without blueprint if none shown
@@ -37,16 +44,21 @@ test.describe("Projects", () => {
}
// Step 2: Timeline — set project dates
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 });
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
if (await projectNameInput.count() > 0) {
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({
timeout: 5000,
});
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 page.locator("button", { hasText: "Next" }).click();
// Step 3: Staffing demand
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 });
await page.locator("button", { hasText: "Next" }).click();
@@ -56,11 +68,13 @@ test.describe("Projects", () => {
// Step 5: Review
await page.waitForTimeout(500);
const reviewOrFinish = page.locator("text=Review").or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
const reviewOrFinish = page
.locator("text=Review")
.or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
// Don't actually submit — just close
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
if (await cancelBtn.count() > 0) {
if ((await cancelBtn.count()) > 0) {
await cancelBtn.click();
}
});
+17 -12
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,16 +16,17 @@ test.describe("Chargeability Report", () => {
test("chargeability forecast page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("h1", { hasText: "Chargeability Forecast" }),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1", { hasText: "Chargeability Forecast" })).toBeVisible({
timeout: 10000,
});
});
test("filter controls are present", async ({ page }) => {
await page.waitForLoadState("networkidle");
// Should have at least one filter (e.g., chapter, period, resource search)
await expect(
page.locator('input[type="text"]')
page
.locator('input[type="text"]')
.or(page.locator('input[type="search"]'))
.or(page.locator("select"))
.first(),
@@ -64,9 +65,9 @@ test.describe("Report Builder", () => {
test("report builder page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.getByRole("heading", { name: "Report Builder" }),
).toBeVisible({ timeout: 10000 });
await expect(page.getByRole("heading", { name: "Report Builder" })).toBeVisible({
timeout: 10000,
});
});
test("entity selector is present with expected options", async ({ page }) => {
@@ -78,9 +79,9 @@ test.describe("Report Builder", () => {
test("run report button is visible", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("button", { hasText: /Run|Export|Generate/i }).first(),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("button", { hasText: /Run|Export|Generate/i }).first()).toBeVisible({
timeout: 10000,
});
});
test("running a default report produces output or empty state", async ({ page }) => {
@@ -90,7 +91,11 @@ test.describe("Report Builder", () => {
await runBtn.click();
await page.waitForTimeout(1500);
await expect(
page.locator("table").or(page.locator("text=No rows")).or(page.locator("text=0 rows")).first(),
page
.locator("table")
.or(page.locator("text=No rows"))
.or(page.locator("text=0 rows"))
.first(),
).toBeVisible({ timeout: 15000 });
}
});
+6 -5
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Resources", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@capakraken.dev");
await page.fill('input[type="email"]', "manager@nexus.dev");
await page.fill('input[type="password"]', "manager123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -21,10 +21,11 @@ test.describe("Resources", () => {
await expect(rows.first()).toBeVisible();
const firstRowText = (await rows.first().textContent()) ?? "";
const searchTerm = firstRowText
.split(/\s+/)
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
.find((token) => token.length >= 3) ?? "EMP";
const searchTerm =
firstRowText
.split(/\s+/)
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
.find((token) => token.length >= 3) ?? "EMP";
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(searchTerm);
+6 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,15 +16,16 @@ test.describe("Scenario Planning", () => {
test("scenarios page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("h1", { hasText: /Scenario Planning/i }),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1", { hasText: /Scenario Planning/i })).toBeVisible({
timeout: 10000,
});
});
test("shows scenarios list or empty state", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("table")
page
.locator("table")
.or(page.locator("text=No scenarios"))
.or(page.locator("text=Create a project first"))
.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 }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
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 }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
+10 -6
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Staffing", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -12,7 +12,9 @@ test.describe("Staffing", () => {
test("staffing page loads with search form", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({
timeout: 10000,
});
// Search form should have skill input, date fields, and a search button
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
});
@@ -20,9 +22,9 @@ test.describe("Staffing", () => {
test("search form has default skill tags", async ({ page }) => {
await page.waitForLoadState("networkidle");
// The StaffingPanel pre-populates with TypeScript and React skill tags
await expect(
page.locator("text=TypeScript").or(page.locator("text=React")),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=TypeScript").or(page.locator("text=React"))).toBeVisible({
timeout: 10000,
});
});
test("submitting search returns suggestions or empty state", async ({ page }) => {
@@ -34,7 +36,9 @@ test.describe("Staffing", () => {
await page.waitForTimeout(1000);
// After search, should show either suggestion cards or a "no suggestions" message
await expect(
page.locator("text=/Score|Availability|No suggestions|No matching/i").first()
page
.locator("text=/Score|Availability|No suggestions|No matching/i")
.first()
.or(page.locator("[data-suggestion]").first())
.or(page.locator("table").first()),
).toBeVisible({ timeout: 15000 });
+8 -8
View File
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
const composeProjectName = `capakraken-e2e-${process.pid}`;
const composeProjectName = `nexus-e2e-${process.pid}`;
const managedEnvKeys = [
"DATABASE_URL",
"REDIS_URL",
@@ -29,7 +29,7 @@ const managedEnvKeys = [
"NODE_ENV",
"PORT",
];
const e2eComposePrefix = "capakraken-e2e-";
const e2eComposePrefix = "nexus-e2e-";
function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args];
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
try {
await runQuiet(
"docker",
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
workspaceRoot,
);
return;
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
if (selectedTestDbPort !== undefined) {
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
}
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
@@ -393,9 +393,9 @@ try {
await cleanupStaleE2eArtifacts();
await ensureE2eDatabaseContainer();
}
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
await run("pnpm", ["--filter", "@nexus/db", "db:push"], workspaceRoot);
await run("pnpm", ["--filter", "@nexus/db", "db:seed"], workspaceRoot);
await run("pnpm", ["--filter", "@nexus/db", "db:seed:holidays"], workspaceRoot);
rmSync(webDistDirPath, { recursive: true, force: true });
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
+289 -162
View File
@@ -133,7 +133,7 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
data: {
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)},
email: ${JSON.stringify(`e2e.timeline.${suffix}@nexus.dev`)},
chapter: "E2E",
lcrCents: 5000,
ucrCents: 9000,
@@ -208,7 +208,7 @@ function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
data: {
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)},
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@nexus.dev`)},
chapter: "E2E",
lcrCents: 5000,
ucrCents: 9000,
@@ -341,7 +341,9 @@ function listScenarioAssignments(projectId: string) {
}
function listScenarioDemands(projectId: string) {
return runDbJson<Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>>(`
return runDbJson<
Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>
>(`
const demands = await prisma.demandRequirement.findMany({
where: { projectId: ${JSON.stringify(projectId)} },
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
@@ -448,10 +450,7 @@ 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 box = await readBoundingBox(target);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
@@ -511,9 +510,7 @@ async function listRenderedAllocationSegments(
row: ReturnType<Page["locator"]>,
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) =>
elements.map((element) => {
const htmlElement = element as HTMLElement;
@@ -536,17 +533,13 @@ function escapeRegex(value: string) {
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
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) => {
for (const element of elements) {
if (!(element instanceof HTMLElement)) continue;
@@ -600,9 +593,9 @@ async function findVisibleAllocationSegmentForResize(
);
const stickyHeaderBottom = scrollContainer
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
0,
)
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
0,
)
: 0;
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
const candidates: Array<{
@@ -611,8 +604,11 @@ async function findVisibleAllocationSegmentForResize(
segmentEnd: string | null;
score: number;
}> = [];
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
null;
let fallback: {
allocationId: string;
segmentStart: string | null;
segmentEnd: string | null;
} | null = null;
for (const element of elements) {
if (!(element instanceof HTMLElement)) continue;
@@ -829,13 +825,20 @@ async function switchToProjectView(page: Page, readySelector?: string) {
await expect(page.locator(readySelector).first()).toBeVisible();
} else {
await expect
.poll(async () => {
const projectRows = await page.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 emptyStates = await page.getByText(/No projects in this time range/).count();
return projectRows + projectBars + demandBars + emptyStates;
}, { timeout: 10_000 })
.poll(
async () => {
const projectRows = await page
.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 emptyStates = await page.getByText(/No projects in this time range/).count();
return projectRows + projectBars + demandBars + emptyStates;
},
{ timeout: 10_000 },
)
.not.toBe(0);
}
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
@@ -853,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
async function ensureOpenDemandVisibilityEnabled(page: Page) {
await page.evaluate(() => {
const raw = window.localStorage.getItem("capakraken_prefs");
const raw = window.localStorage.getItem("nexus_prefs");
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
window.localStorage.setItem(
"capakraken_prefs",
"nexus_prefs",
JSON.stringify({
...parsed,
showDemandProjects: true,
@@ -871,9 +874,9 @@ test.describe("Timeline", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem(
"capakraken_prefs",
"nexus_prefs",
JSON.stringify({
hideCompletedProjects: true,
timelineDisplayMode: "strip",
@@ -906,22 +909,21 @@ test.describe("Timeline", () => {
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
});
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ page }) => {
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({
page,
}) => {
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const scenario = createTimelineSegmentScenario(suffix);
try {
await page.goto(
`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`,
{ waitUntil: "domcontentloaded" },
);
await page.goto(`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, {
waitUntil: "domcontentloaded",
});
const projectButton = page.getByRole("button", { name: "Project view" });
const resourceButton = page.getByRole("button", { name: "Resource view" });
const resourceRowSelector =
`[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}"]`;
const resourceRowSelector = `[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(resourceButton).toBeDisabled();
@@ -951,9 +953,9 @@ test.describe("Timeline", () => {
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
await page.goto("/allocations");
await expect(
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
timeout: 10000,
});
await page.locator('nav a >> text="Timeline"').first().click();
await expect(page).toHaveURL(/\/timeline/);
@@ -1046,7 +1048,10 @@ test.describe("Timeline", () => {
if (!projectAllocationBox) {
throw new Error("Expected a project allocation block to be available");
}
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
await page.mouse.move(
projectAllocationBox.x + projectAllocationBox.width / 2,
projectHoverBox.y + 20,
);
await expect(heatmapTooltip).toBeVisible();
await expect
.poll(async () => {
@@ -1071,7 +1076,9 @@ test.describe("Timeline", () => {
.first();
await allocation.click({ button: "right" });
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
timeout: 2_000,
});
const popover = page.getByTestId("timeline-allocation-popover");
await expect(popover).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
@@ -1103,12 +1110,16 @@ test.describe("Timeline", () => {
waitUntil: "domcontentloaded",
});
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
const row = page
.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]')
.first();
await expect(row).toBeVisible();
const holidayBlock = row.locator(
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
).first();
const holidayBlock = row
.locator(
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
)
.first();
await expect(holidayBlock).toBeVisible();
const rowBox = await row.boundingBox();
@@ -1129,7 +1140,9 @@ test.describe("Timeline", () => {
const holidayTooltip = page
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
.or(
page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }),
)
.first();
await expect(holidayTooltip).toBeVisible();
@@ -1278,9 +1291,7 @@ test.describe("Timeline", () => {
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", {
waitUntil: "domcontentloaded",
});
@@ -1358,9 +1369,7 @@ test.describe("Timeline", () => {
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", {
waitUntil: "domcontentloaded",
});
@@ -1394,18 +1403,17 @@ test.describe("Timeline", () => {
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
waitUntil: "domcontentloaded",
});
const resourceRowSelector =
`[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}"]`;
const projectAllocationSelector =
`${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
const resourceRowSelector = `[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}"]`;
const projectAllocationSelector = `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
await expect(page.locator(resourceRowSelector)).toBeVisible();
await expect(
page.locator(
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
).first(),
page
.locator(
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
)
.first(),
).toBeVisible();
await switchToProjectView(page, projectRowSelector);
@@ -1427,19 +1435,22 @@ test.describe("Timeline", () => {
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
await expect
.poll(() => {
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
if (rightResizeAssignments.length !== 1) {
return null;
}
.poll(
() => {
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
if (rightResizeAssignments.length !== 1) {
return null;
}
const [assignment] = rightResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) {
return null;
}
const [assignment] = rightResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) {
return null;
}
return assignment.endDate;
}, { timeout: 15_000 })
return assignment.endDate;
},
{ timeout: 15_000 },
)
.not.toBe("2026-04-17");
expect(rightResizeAssignments).toHaveLength(1);
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
@@ -1451,19 +1462,22 @@ test.describe("Timeline", () => {
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
await expect
.poll(() => {
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
if (leftResizeAssignments.length !== 1) {
return null;
}
.poll(
() => {
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
if (leftResizeAssignments.length !== 1) {
return null;
}
const [assignment] = leftResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) {
return null;
}
const [assignment] = leftResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) {
return null;
}
return assignment.startDate;
}, { timeout: 15_000 })
return assignment.startDate;
},
{ timeout: 15_000 },
)
.not.toBe("2026-04-06");
expect(leftResizeAssignments).toHaveLength(1);
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
@@ -1479,15 +1493,12 @@ test.describe("Timeline", () => {
const scenario = createTimelineDemandScenario(suffix);
try {
await page.goto(
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
{ waitUntil: "domcontentloaded" },
);
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
waitUntil: "domcontentloaded",
});
await ensureOpenDemandVisibilityEnabled(page);
const demandRowSelector =
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
const demandSelector =
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
const demandRowSelector = `[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 expect(page.getByText(scenario.projectName).first()).toBeVisible();
@@ -1505,19 +1516,22 @@ test.describe("Timeline", () => {
status: string;
}> = [];
await expect
.poll(() => {
rightResizeDemands = listScenarioDemands(scenario.projectId);
if (rightResizeDemands.length !== 1) {
return null;
}
.poll(
() => {
rightResizeDemands = listScenarioDemands(scenario.projectId);
if (rightResizeDemands.length !== 1) {
return null;
}
const [demand] = rightResizeDemands;
if (!demand || demand.id !== scenario.demandId) {
return null;
}
const [demand] = rightResizeDemands;
if (!demand || demand.id !== scenario.demandId) {
return null;
}
return demand.endDate;
}, { timeout: 15_000 })
return demand.endDate;
},
{ timeout: 15_000 },
)
.not.toBe("2026-04-16");
expect(rightResizeDemands).toHaveLength(1);
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
@@ -1538,19 +1552,22 @@ test.describe("Timeline", () => {
status: string;
}> = [];
await expect
.poll(() => {
leftResizeDemands = listScenarioDemands(scenario.projectId);
if (leftResizeDemands.length !== 1) {
return null;
}
.poll(
() => {
leftResizeDemands = listScenarioDemands(scenario.projectId);
if (leftResizeDemands.length !== 1) {
return null;
}
const [demand] = leftResizeDemands;
if (!demand || demand.id !== scenario.demandId) {
return null;
}
const [demand] = leftResizeDemands;
if (!demand || demand.id !== scenario.demandId) {
return null;
}
return demand.startDate;
}, { timeout: 15_000 })
return demand.startDate;
},
{ timeout: 15_000 },
)
.not.toBe("2026-04-07");
expect(leftResizeDemands).toHaveLength(1);
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
@@ -1630,7 +1647,11 @@ test.describe("Timeline", () => {
);
await expect(resizedSegment).toBeVisible();
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
await dragLocatorBy(
page,
resizedSegment.locator('[data-allocation-interaction="body"]'),
-dayWidth,
);
await releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [
@@ -1674,9 +1695,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-11", endDate: "2026-04-17" },
]);
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').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();
const leftSplit = row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.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(rightSplit).toBeVisible();
await expect(nextWeekSegment).toBeVisible();
@@ -1704,22 +1737,42 @@ test.describe("Timeline", () => {
]);
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
)
.first(),
).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -1769,9 +1822,21 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').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();
const leftSplit = row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.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(fridayBridge).toBeVisible();
await expect(mondaySegment).toBeVisible();
@@ -1797,13 +1862,25 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -1850,9 +1927,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').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();
const leftSplit = row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.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(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
@@ -1870,8 +1959,16 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const resizedRightSplit = row.locator('[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);
const resizedRightSplit = row
.locator(
'[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 waitForScenarioAssignments(scenario.projectId, [
@@ -1883,9 +1980,11 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
const mondaySegmentAfterReload = row.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
).first();
const mondaySegmentAfterReload = row
.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first();
await expect(mondaySegmentAfterReload).toBeVisible();
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
@@ -1951,9 +2050,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').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();
const leftSplit = row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.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(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
@@ -1968,7 +2079,11 @@ test.describe("Timeline", () => {
]);
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.first(),
).toHaveCount(0);
await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible();
@@ -1976,13 +2091,25 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
)
.first(),
).toHaveCount(0);
await expect(
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
)
.first(),
).toBeVisible();
await expect(
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
row
.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first(),
).toBeVisible();
} finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -2029,13 +2156,14 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" },
]);
const mondaySegment = resourceRow.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
).first();
const mondaySegment = resourceRow
.locator(
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first();
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);
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
@@ -2072,7 +2200,9 @@ test.describe("Timeline", () => {
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
const projectAllocationAfterReload = page
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
.locator(
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
)
.first();
await expect(projectAllocationAfterReload).toBeVisible();
await openContextMenuAtCenter(page, projectAllocationAfterReload);
@@ -2093,15 +2223,12 @@ test.describe("Timeline", () => {
const scenario = createTimelineDemandScenario(suffix);
try {
await page.goto(
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
{ waitUntil: "domcontentloaded" },
);
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
waitUntil: "domcontentloaded",
});
await ensureOpenDemandVisibilityEnabled(page);
const demandRowSelector =
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
const demandSelector =
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
const demandRowSelector = `[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 expect(page.locator(demandSelector)).toBeVisible();
+28 -11
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -27,9 +27,9 @@ test.describe("Vacations", () => {
test("request vacation button is visible", async ({ page }) => {
await page.waitForLoadState("networkidle");
await expect(
page.locator("button", { hasText: /Request Vacation/i }),
).toBeVisible({ timeout: 10000 });
await expect(page.locator("button", { hasText: /Request Vacation/i })).toBeVisible({
timeout: 10000,
});
});
test("request vacation is blocked without linked resource", async ({ page }) => {
@@ -37,7 +37,9 @@ test.describe("Vacations", () => {
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
await expect(reqBtn).toBeDisabled();
await expect(
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
page.getByText(
"Your account is not linked to a resource. Please contact an administrator.",
),
).toBeVisible({ timeout: 5000 });
});
});
@@ -57,11 +59,18 @@ test.describe("Vacations", () => {
test("team calendar tab renders", async ({ page }) => {
await page.waitForLoadState("networkidle");
await page.locator("button", { hasText: "Team Calendar" }).or(page.locator("text=Team Calendar")).first().click();
await page
.locator("button", { hasText: "Team Calendar" })
.or(page.locator("text=Team Calendar"))
.first()
.click();
await page.waitForTimeout(500);
// Calendar view should appear
await expect(
page.locator("table").or(page.locator("[data-calendar]")).or(page.locator("text=Mon").or(page.locator("text=Week"))),
page
.locator("table")
.or(page.locator("[data-calendar]"))
.or(page.locator("text=Mon").or(page.locator("text=Week"))),
).toBeVisible({ timeout: 10000 });
});
@@ -75,11 +84,15 @@ test.describe("Vacations", () => {
await expect(filters.nth(2)).toHaveValue("");
});
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
test("vacation request preview excludes regional public holidays from deducted days", async ({
page,
}) => {
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /request vacation/i }).click();
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
await expect(
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(/^type/i).selectOption("ANNUAL");
await fillDisplayDate(page, /start date/i, "2026-01-06");
@@ -89,9 +102,13 @@ test.describe("Vacations", () => {
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-deducted-days")).toHaveText("0");
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText(
"2026-01-06",
);
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText(
"Holiday Calendar",
);
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import nextjsConfig from "@capakraken/eslint-config/nextjs";
import nextjsConfig from "@nexus/eslint-config/nextjs";
/** @type {import("eslint").Linter.FlatConfig[]} */
export default [
+6 -6
View File
@@ -11,16 +11,16 @@ const nextConfig: NextConfig = {
"recharts",
"date-fns",
"framer-motion",
"@capakraken/shared",
"@nexus/shared",
"@react-pdf/renderer",
],
},
transpilePackages: [
"@capakraken/api",
"@capakraken/db",
"@capakraken/engine",
"@capakraken/shared",
"@capakraken/staffing",
"@nexus/api",
"@nexus/db",
"@nexus/engine",
"@nexus/shared",
"@nexus/staffing",
],
typedRoutes: true,
eslint: {
+9 -9
View File
@@ -1,5 +1,5 @@
{
"name": "@capakraken/web",
"name": "@nexus/web",
"version": "0.1.0",
"private": true,
"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"
},
"dependencies": {
"@capakraken/api": "workspace:*",
"@capakraken/application": "workspace:*",
"@capakraken/db": "workspace:*",
"@capakraken/engine": "workspace:*",
"@capakraken/shared": "workspace:*",
"@nexus/api": "workspace:*",
"@nexus/application": "workspace:*",
"@nexus/db": "workspace:*",
"@nexus/engine": "workspace:*",
"@nexus/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -34,7 +34,7 @@
"dompurify": "^3.4.0",
"exceljs": "^4.4.0",
"framer-motion": "^12.38.0",
"next": "^15.5.15",
"next": "^15.5.16",
"next-auth": "^5.0.0-beta.25",
"otpauth": "^9.5.0",
"qrcode": "^1.5.4",
@@ -51,8 +51,8 @@
"devDependencies": {
"@next/bundle-analyzer": "^16.2.3",
"@axe-core/playwright": "^4.11.1",
"@capakraken/eslint-config": "workspace:*",
"@capakraken/tsconfig": "workspace:*",
"@nexus/eslint-config": "workspace:*",
"@nexus/tsconfig": "workspace:*",
"@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.9.1",
"@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.
*
* Usage:
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts
* pnpm --filter @nexus/web exec playwright test --config playwright.dev.config.ts
*
* Prerequisites:
* - Dev server running: pnpm run dev (or docker compose up)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "CapaKraken — Resource & Capacity Planning",
"short_name": "CapaKraken",
"name": "Nexus — Resource & Capacity Planning",
"short_name": "Nexus",
"description": "Resource planning and project staffing for 3D production",
"start_url": "/dashboard",
"display": "standalone",
+3 -3
View File
@@ -1,6 +1,6 @@
/// <reference lib="webworker" />
const CACHE_NAME = "capakraken-v2";
const CACHE_NAME = "nexus-v2";
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
// Offline fallback page (simple inline HTML)
@@ -9,7 +9,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CapaKraken - Offline</title>
<title>Nexus - Offline</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -31,7 +31,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
<body>
<div class="container">
<h1>You are offline</h1>
<p>CapaKraken requires an internet connection. Please check your network and try again.</p>
<p>Nexus requires an internet connection. Please check your network and try again.</p>
<button onclick="location.reload()">Retry</button>
</div>
</body>
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — CapaKraken" };
export const metadata = { title: "Vacation Management — Nexus" };
export default function AdminVacationsPage() {
return (
@@ -10,15 +10,19 @@ export default function AdminVacationsPage() {
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
Fallback-Importe.
</p>
</div>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Holiday Calendars
</h2>
<p className="text-sm text-gray-600">
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung,
Timeline-Overlay und Assistant-Abfragen verwendet.
</p>
</div>
<HolidayCalendarEditor />
@@ -26,9 +30,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Legacy Batch Import
</h2>
<p className="text-sm text-gray-600">
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die
Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
</p>
</div>
<PublicHolidayBatch />
@@ -36,9 +43,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Entitlements
</h2>
<p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
aufgeloest wurden.
</p>
</div>
<EntitlementManager />
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared";
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -122,7 +122,8 @@ function EstimateDetailPanel({
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
Estimate detail{" "}
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
</p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name}
@@ -206,7 +207,8 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
Scope items{" "}
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div>
@@ -239,7 +241,8 @@ function EstimateDetailPanel({
<section>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
Demand lines{" "}
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
</h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div>
@@ -345,13 +348,19 @@ function EstimateCard({
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<div>
<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>
<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>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"}
</p>
</div>
<div>
<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>
<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>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)}
</p>
@@ -466,7 +475,7 @@ export function EstimatesClient() {
No estimates yet
</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from CapaKraken data.
Start with the wizard to create a connected estimate from Nexus data.
</p>
</div>
) : (
+1 -1
View File
@@ -1,7 +1,7 @@
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
export const metadata = {
title: "CapaKraken — Mobile Summary",
title: "Nexus — Mobile Summary",
};
export default function MobilePage() {
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
import Link from "next/link";
import Image from "next/image";
import { clsx } from "clsx";
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link";
import type { Resource, SkillEntry } from "@capakraken/shared";
import { RESOURCE_COLUMNS } from "@capakraken/shared";
import { BlueprintTarget, ResourceType } from "@capakraken/shared";
import type { Resource, SkillEntry } from "@nexus/shared";
import { RESOURCE_COLUMNS } from "@nexus/shared";
import { BlueprintTarget, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
@@ -945,7 +945,7 @@ export function ResourcesClient() {
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Unique employee identifier used across all CapaKraken records."
tooltip="Unique employee identifier used across all Nexus records."
/>
);
case "displayName":
+8 -10
View File
@@ -2,24 +2,22 @@ import type { Metadata } from "next";
import { createCaller } from "~/server/trpc.js";
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | CapaKraken` };
return { title: `${resource.displayName} — Resources | Nexus` };
} catch {
return { title: "Resource — CapaKraken" };
return { title: "Resource — Nexus" };
}
}
export default async function ResourceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ResourceDetail resourceId={id} />;
}
@@ -1,5 +1,5 @@
import type { Resource } from "@capakraken/shared";
import { ResourceType } from "@capakraken/shared";
import type { Resource } from "@nexus/shared";
import { ResourceType } from "@nexus/shared";
export type ModalState =
| { type: "closed" }
+1 -1
View File
@@ -1,6 +1,6 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — CapaKraken" };
export const metadata = { title: "My Vacations — Nexus" };
export default function MyVacationsPage() {
return <MyVacationsClient />;
@@ -1,4 +1,4 @@
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
/** Window over which auth events are analysed. */
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
const userFindManyMock = vi.hoisted(() => vi.fn());
vi.mock("@capakraken/db", () => ({
vi.mock("@nexus/db", () => ({
prisma: {
auditLog: { findMany: auditLogFindManyMock },
user: { findMany: userFindManyMock },
@@ -27,11 +27,11 @@ vi.mock("@capakraken/db", () => ({
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@capakraken/api", () => ({
vi.mock("@nexus/api", () => ({
createNotificationsForUsers: createNotificationsMock,
}));
vi.mock("@capakraken/api/lib/logger", () => ({
vi.mock("@nexus/api/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
const { GET } = await importRoute();
const res = await GET(makeRequest());
expect(res.status).toBe(401);
});
}, 15_000); // next/server cold-import can take >5s on the act runner
it("proceeds when verifyCronSecret returns null (allowed)", async () => {
verifyCronSecretMock.mockReturnValue(null);
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
import { detectAuthAnomalies } from "./detect.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { checkChargeabilityAlerts } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { checkChargeabilityAlerts } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { checkPendingEstimateReminders } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { checkPendingEstimateReminders } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { createConnection } from "net";
import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { autoImportPublicHolidays } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { autoImportPublicHolidays } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
skippedExisting: result.skippedExisting,
});
} catch (error) {
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
return NextResponse.json(
{ ok: false, error: "Internal error" },
{ status: 500 },
logger.error(
{ error, route: "/api/cron/public-holidays", year },
"Public holiday import cron failed",
);
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
}
}
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { createNotificationsForUsers } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { readFileSync } from "fs";
import { join } from "path";
import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { sendWeeklyDigest } from "@capakraken/api";
import { logger } from "@capakraken/api/lib/logger";
import { prisma } from "@nexus/db";
import { sendWeeklyDigest } from "@nexus/api";
import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
+9 -3
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { createConnection } from "net";
export const dynamic = "force-dynamic";
@@ -30,8 +30,14 @@ async function checkRedis(): Promise<"ok" | "error"> {
socket.destroy();
resolve(data.toString().includes("PONG") ? "ok" : "error");
});
socket.on("timeout", () => { socket.destroy(); resolve("error"); });
socket.on("error", () => { socket.destroy(); resolve("error"); });
socket.on("timeout", () => {
socket.destroy();
resolve("error");
});
socket.on("error", () => {
socket.destroy();
resolve("error");
});
} catch {
resolve("error");
}
+7 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/api/sse", () => ({
vi.mock("@nexus/api/sse", () => ({
eventBus: { subscriberCount: 0 },
}));
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request);
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(body.uptime).toBeDefined();
expect(body.memory).toBeDefined();
@@ -81,7 +81,11 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request);
expect(response.status).toBe(401);
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
const body = (await response.json()) as {
error?: string;
timestamp?: string;
memory?: unknown;
};
expect(body.timestamp).toBeUndefined();
expect(body.memory).toBeUndefined();
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { eventBus } from "@capakraken/api/sse";
import { eventBus } from "@nexus/api/sse";
import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic";
+3 -6
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { createConnection } from "net";
export const dynamic = "force-dynamic";
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
/**
* Lightweight Redis PING check using a raw TCP socket.
* Avoids importing ioredis (which is only a dependency of @capakraken/api).
* Avoids importing ioredis (which is only a dependency of @nexus/api).
*/
async function checkRedis(): Promise<"ok" | "error"> {
return new Promise((resolve) => {
@@ -58,10 +58,7 @@ async function checkRedis(): Promise<"ok" | "error"> {
}
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";
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
vi.mock("@capakraken/db", () => ({
vi.mock("@nexus/db", () => ({
prisma: {
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
assignment: { findMany: vi.fn().mockResolvedValue([]) },
@@ -21,11 +21,11 @@ vi.mock("@capakraken/db", () => ({
},
}));
vi.mock("@capakraken/application", () => ({
vi.mock("@nexus/application", () => ({
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
}));
vi.mock("@capakraken/api", () => ({
vi.mock("@nexus/api", () => ({
anonymizeResource: vi.fn((r: unknown) => r),
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
}));
@@ -1,16 +1,28 @@
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { NextResponse } from "next/server";
import { buildSplitAllocationReadModel } from "@capakraken/application";
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
import { prisma } from "@capakraken/db";
import type { AllocationLike } from "@capakraken/shared";
import { z } from "zod";
import { buildSplitAllocationReadModel } from "@nexus/application";
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
import { prisma } from "@nexus/db";
import type { AllocationLike } from "@nexus/shared";
import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js";
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
// certainly malformed input and would generate nonsensical SQL range scans.
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
const queryParamsSchema = z.object({
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
format: z.enum(["pdf", "xlsx"]).default("pdf"),
});
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) {
@@ -23,9 +35,20 @@ export async function GET(request: Request) {
}
const { searchParams } = new URL(request.url);
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const format = searchParams.get("format") ?? "pdf";
const parsed = queryParamsSchema.safeParse({
startDate: searchParams.get("startDate") ?? undefined,
endDate: searchParams.get("endDate") ?? undefined,
format: searchParams.get("format") ?? undefined,
});
if (!parsed.success) {
return new NextResponse("Invalid query parameters", { status: 400 });
}
const startDate = parsed.data.startDate ?? new Date();
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
if (endDate < startDate) {
return new NextResponse("endDate must be >= startDate", { status: 400 });
}
const format = parsed.data.format;
const [demandRequirements, assignments] = await Promise.all([
prisma.demandRequirement.findMany({
@@ -62,21 +85,25 @@ export async function GET(request: Request) {
const assignmentRows = allocationView.assignments.slice(0, 500);
const directory = await getAnonymizationDirectory(prisma);
const rows = assignmentRows.map((a: AllocationLike & {
resource?: { id: string; displayName?: string | null } | null;
project?: { shortCode: string; name: string } | null;
}) => {
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
return {
resourceName: resource?.displayName ?? "Unknown",
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
role: a.role ?? "",
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
};
});
const rows = assignmentRows.map(
(
a: AllocationLike & {
resource?: { id: string; displayName?: string | null } | null;
project?: { shortCode: string; name: string } | null;
},
) => {
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
return {
resourceName: resource?.displayName ?? "Unknown",
projectName: a.project ? `${a.project.shortCode}${a.project.name}` : "Unknown project",
role: a.role ?? "",
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
hoursPerDay: a.hoursPerDay,
dailyCostCents: a.dailyCostCents,
};
},
);
const ts = Date.now();
+34 -6
View File
@@ -1,14 +1,19 @@
import { loadRoleDefaults } from "@capakraken/api";
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
import { prisma } from "@capakraken/db";
import type { SystemRole } from "@capakraken/shared";
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
import { loadRoleDefaults } from "@nexus/api";
import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
import { prisma } from "@nexus/db";
import type { SystemRole } from "@nexus/shared";
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
// Bounded connection tracking: a single user opening 100 tabs should not be
// able to pin 100 persistent subscriptions on this node.
const MAX_SSE_CONNECTIONS_PER_USER = 8;
const sseConnectionsByUser = new Map<string, number>();
export async function GET() {
// Start lazily on the first real SSE request so builds/import-time evaluation
// never attempt reminder processing against a live database.
@@ -43,6 +48,24 @@ export async function GET() {
return new Response("Unauthorized", { status: 401 });
}
const currentCount = sseConnectionsByUser.get(dbUser.id) ?? 0;
if (currentCount >= MAX_SSE_CONNECTIONS_PER_USER) {
return new Response("Too many SSE connections", {
status: 429,
headers: { "Retry-After": "30" },
});
}
sseConnectionsByUser.set(dbUser.id, currentCount + 1);
const releaseSlot = () => {
const next = (sseConnectionsByUser.get(dbUser.id) ?? 1) - 1;
if (next <= 0) {
sseConnectionsByUser.delete(dbUser.id);
} else {
sseConnectionsByUser.set(dbUser.id, next);
}
};
const roleDefaults = await loadRoleDefaults();
const subscription = deriveUserSseSubscription(
{
@@ -85,6 +108,7 @@ export async function GET() {
} catch {
clearInterval(heartbeat);
unsubscribe();
releaseSlot();
}
}, 30000);
@@ -92,8 +116,12 @@ export async function GET() {
return () => {
clearInterval(heartbeat);
unsubscribe();
releaseSlot();
};
},
cancel() {
releaseSlot();
},
});
return new Response(stream, {
+25 -3
View File
@@ -1,6 +1,6 @@
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api";
import { appRouter } from "@capakraken/api/router";
import { prisma } from "@capakraken/db";
import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
import { appRouter } from "@nexus/api/router";
import { prisma } from "@nexus/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server";
@@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null {
return null;
}
// Hard cap on tRPC request body size to prevent memory/CPU amplification from
// a single oversized payload. Stream uploads (files, reports) don't go through
// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call.
const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024;
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
@@ -37,6 +42,23 @@ function trackActivity(userId: string) {
}
const handler = async (req: NextRequest) => {
// Reject oversized bodies before we touch auth, DB, or the router. A tRPC
// mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is
// advisory — also guard against chunked requests below via length check
// on the cloned body.
if (req.method !== "GET") {
const declaredLength = req.headers.get("content-length");
if (declaredLength) {
const parsed = Number(declaredLength);
if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) {
return new Response(JSON.stringify({ error: "Request body too large" }), {
status: 413,
headers: { "Content-Type": "application/json" },
});
}
}
}
const session = await auth();
// Validate active session registry on every authenticated request.
@@ -2,7 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
+73 -13
View File
@@ -10,10 +10,13 @@ export default function SignInPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [backupCode, setBackupCode] = useState("");
const [useBackupCode, setUseBackupCode] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [mfaRequired, setMfaRequired] = useState(false);
const totpInputRef = useRef<HTMLInputElement>(null);
const backupCodeInputRef = useRef<HTMLInputElement>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -23,7 +26,8 @@ export default function SignInPage() {
const result = await signIn("credentials", {
email,
password,
...(mfaRequired ? { totp } : {}),
...(mfaRequired && !useBackupCode ? { totp } : {}),
...(mfaRequired && useBackupCode ? { backupCode } : {}),
redirect: false,
});
@@ -47,8 +51,13 @@ export default function SignInPage() {
return;
}
if (code === "INVALID_TOTP") {
setError("Invalid verification code. Please try again.");
setError(
useBackupCode
? "Invalid backup code. Please try again."
: "Invalid verification code. Please try again.",
);
setTotp("");
setBackupCode("");
setLoading(false);
return;
}
@@ -57,6 +66,8 @@ export default function SignInPage() {
if (mfaRequired) {
setMfaRequired(false);
setTotp("");
setBackupCode("");
setUseBackupCode(false);
}
} else {
// Full-page navigation instead of router.push to guarantee a fresh
@@ -76,6 +87,8 @@ export default function SignInPage() {
function handleBackToLogin() {
setMfaRequired(false);
setTotp("");
setBackupCode("");
setUseBackupCode(false);
setError("");
}
@@ -85,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>
<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">
CapaKraken Control Center
Nexus Control Center
</span>
<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.
@@ -124,7 +137,7 @@ export default function SignInPage() {
Welcome Back
</p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
{mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
</h2>
<p className="mt-2 text-sm text-gray-500">
{mfaRequired
@@ -183,7 +196,7 @@ export default function SignInPage() {
</>
)}
{mfaRequired && (
{mfaRequired && !useBackupCode && (
<div>
<label htmlFor="totp" className="app-label">
Verification Code
@@ -209,22 +222,69 @@ export default function SignInPage() {
</div>
)}
{mfaRequired && useBackupCode && (
<div>
<label htmlFor="backup-code" className="app-label">
Backup Code
</label>
<input
ref={backupCodeInputRef}
id="backup-code"
type="text"
autoComplete="one-time-code"
maxLength={16}
value={backupCode}
onChange={(e) => setBackupCode(e.target.value.toUpperCase().slice(0, 16))}
className="app-input text-center text-xl font-mono tracking-[0.2em] uppercase"
placeholder="XXXXX-XXXXX"
required
autoFocus
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Each backup code works once. You'll need to regenerate your codes after using
one.
</p>
</div>
)}
<button
type="submit"
disabled={loading || (mfaRequired && totp.length !== 6)}
disabled={
loading ||
(mfaRequired && !useBackupCode && totp.length !== 6) ||
(mfaRequired && useBackupCode && backupCode.replace(/[\s-]/g, "").length < 8)
}
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
</button>
{mfaRequired && (
<button
type="button"
onClick={handleBackToLogin}
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back to login
</button>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => {
setUseBackupCode((v) => !v);
setError("");
setTotp("");
setBackupCode("");
setTimeout(() => {
if (useBackupCode) totpInputRef.current?.focus();
else backupCodeInputRef.current?.focus();
}, 100);
}}
className="w-full text-center text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
>
{useBackupCode ? "Use authenticator code instead" : "Use a backup code instead"}
</button>
<button
type="button"
onClick={handleBackToLogin}
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Back to login
</button>
</div>
)}
</form>
</div>
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, use } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
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">
<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">
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
You have been invited as <strong>{invite.role}</strong> to Nexus. Set a password to
activate your account (<span className="font-medium">{invite.email}</span>).
</p>
</div>
+51 -10
View File
@@ -19,8 +19,8 @@ const displayFont = Manrope({
});
export const metadata: Metadata = {
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"),
title: "CapaKraken — Resource & Capacity Planning",
metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
title: "Nexus — Resource & Capacity Planning",
description: "Interactive resource planning and project staffing tool",
manifest: "/manifest.json",
icons: {
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "CapaKraken",
title: "Nexus",
},
openGraph: {
title: "CapaKraken — Resource & Capacity Planning",
title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }],
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "CapaKraken — Resource & Capacity Planning",
title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: ["/og-image.png"],
},
@@ -60,15 +60,56 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang="en" suppressHydrationWarning>
<head>
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: `
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
try {
var p = JSON.parse(localStorage.getItem('capakraken_theme') || '{}');
if (!localStorage.getItem('nexus_migrated_v1')) {
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.accent) document.documentElement.setAttribute('data-accent', p.accent);
} catch(e) {}
`}} />
`,
}}
/>
</head>
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
<body
className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}
>
<TRPCProvider>{children}</TRPCProvider>
<ServiceWorkerRegistration />
<InstallPrompt />
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { createFirstAdmin } from "./actions.js";
export function SetupClient() {
@@ -76,7 +76,7 @@ export function SetupClient() {
<div className="mb-6">
<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">
Create the initial administrator account for CapaKraken.
Create the initial administrator account for Nexus.
</p>
</div>
+3 -7
View File
@@ -1,11 +1,7 @@
"use server";
import { prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { prisma } from "@nexus/db";
import { SystemRole } from "@nexus/db";
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
export type SetupResult =
| { success: true }
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { prisma } from "@capakraken/db";
import { prisma } from "@nexus/db";
import { SetupClient } from "./SetupClient.js";
export default async function SetupPage() {
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry } from "@nexus/shared";
interface ParsedEntry {
fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased)
candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string;
skills: SkillEntry[];
employeeInfo: Record<string, string>;
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
);
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); },
onError: (err) => { setError(err.message); setSubmitting(false); },
onSuccess: (data) => {
setResult(data);
setSubmitting(false);
},
onError: (err) => {
setError(err.message);
setSubmitting(false);
},
});
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
if (result.employeeInfo.portfolioUrl)
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return {
fileName: file.name,
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
skills: e.skills,
employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
...(e.employeeInfo["portfolioUrl"]
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
: {}),
},
})),
});
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
return (
<div className="p-6 max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Batch Skill Matrix Import
</h1>
<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.
</p>
@@ -149,12 +160,33 @@ 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"
onClick={() => fileRef.current?.click()}
>
<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">
<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
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"
>
<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>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
<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} />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Click to select multiple .xlsx files
</p>
<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>
{/* Summary */}
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
</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">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
unmatched (select EID manually)
</span>
</div>
</div>
)}
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
File
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Resource EID
</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>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => (
<tr key={idx} 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>
<tr
key={idx}
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">
{entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
{entry.selectedEid}
</span>
) : (
<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"
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
>
<option value=""> Select resource </option>
{resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
<option key={r.eid} value={r.eid}>
{r.displayName} ({r.eid})
</option>
))}
</select>
)}
</td>
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</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-right text-gray-700 dark:text-gray-300">
{entry.skills.length}
</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">
<span 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"
}`}>
<span
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}
</span>
</td>
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
)}
{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">{error}</div>
<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>
)}
{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">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
Import complete: <strong>{result.updated}</strong> updated,{" "}
<strong>{result.notFound}</strong> not found.
</div>
)}
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
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"
>
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
{submitting
? "Importing…"
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
</button>
)}
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { SystemRole } from "@capakraken/shared";
import { SystemRole } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!email) { setError("Email is required."); return; }
if (!email) {
setError("Email is required.");
return;
}
await inviteMutation.mutateAsync({ email, role });
}
@@ -96,7 +99,9 @@ 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"
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { PermissionKey } from "@capakraken/shared";
import { PermissionKey } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,6 +1,6 @@
"use client";
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
@@ -1,4 +1,4 @@
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -1,4 +1,4 @@
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
@@ -1,13 +1,13 @@
"use client";
import { useState, useMemo } from "react";
import type { PermissionKey } from "@capakraken/shared";
import type { PermissionKey } from "@nexus/shared";
import {
SystemRole,
ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY,
type PermissionOverrides,
} from "@capakraken/shared";
} from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js";
@@ -176,7 +176,7 @@ export function WebhooksClient() {
<div>
<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">
Configure outbound webhooks to notify external services about events in CapaKraken.
Configure outbound webhooks to notify external services about events in Nexus.
</p>
</div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
@@ -194,10 +194,7 @@ export function WebhooksClient() {
) : (
<div className="space-y-3">
{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 */}
<div
className={`h-3 w-3 shrink-0 rounded-full ${
@@ -209,9 +206,7 @@ export function WebhooksClient() {
{/* Info */}
<div className="min-w-0 flex-1">
<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") && (
<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
@@ -257,17 +252,12 @@ export function WebhooksClient() {
</button>
<button
className={SECONDARY_BUTTON}
onClick={() =>
handleToggleActive(wh.id, wh.isActive)
}
onClick={() => handleToggleActive(wh.id, wh.isActive)}
disabled={updateMut.isPending}
>
{wh.isActive ? "Disable" : "Enable"}
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => openEditModal(wh)}
>
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
Edit
</button>
{deleteConfirmId === wh.id ? (
@@ -282,18 +272,12 @@ export function WebhooksClient() {
>
Confirm
</button>
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(null)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
</div>
) : (
<button
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(wh.id)}
>
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
Delete
</button>
)}
@@ -335,9 +319,7 @@ export function WebhooksClient() {
{/* Secret */}
<div>
<label className={LABEL_CLASS}>
Secret (optional)
</label>
<label className={LABEL_CLASS}>Secret (optional)</label>
<input
className={INPUT_CLASS}
type="password"
@@ -1,4 +1,4 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import {
INPUT_CLASS,
@@ -123,7 +123,9 @@ export function AiProviderPanel({
</p>
) : null}
{urlParsedType === "completions" ? (
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
<p className="text-xs text-green-700 dark:text-green-400">
All fields filled from URL.
</p>
) : null}
</div>
@@ -154,7 +156,7 @@ export function AiProviderPanel({
id="ai-model"
type="text"
className={INPUT_CLASS}
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
value={model}
onChange={(event) => onModelChange(event.target.value)}
/>
@@ -223,12 +225,7 @@ export function AiProviderPanel({
) : null}
<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"}
</button>
<button
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
</div>
<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"}
</button>
{saved ? (
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
className={INPUT_CLASS}
value={smtpFrom}
onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app"
placeholder="noreply@nexus.app"
/>
</div>
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
import { formatDate } from "~/lib/format.js";
import { AllocationRow } from "./AllocationRow.js";
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { toDateInputValue } from "~/lib/format.js";
@@ -26,7 +26,8 @@ interface AllocationModalProps {
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
const initialEntryKind: EntryKind =
allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand";
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: projects } = trpc.project.list.useQuery({ 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
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const shouldCheckConflicts =
!isDemandEntry &&
!!debouncedResourceId &&
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
conflictCheckStart !== null &&
!isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null &&
!isNaN(conflictCheckEnd.getTime()) &&
debouncedHoursPerDay > 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
const { data: conflictResult, isFetching: checkingConflicts } =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(trpc.allocation.checkConflicts.useQuery as any)(
{
resourceId: debouncedResourceId,
startDate: conflictCheckStart,
endDate: conflictCheckEnd,
hoursPerDay: debouncedHoursPerDay,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
},
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
) as {
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
isFetching: boolean;
};
const overlapWarning = useMemo(() => {
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const formEnd = new Date(endDate);
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
const allocList =
(
existingAllocations as {
allocations?: Array<{
id: string;
resourceId?: string | null;
startDate: string | Date;
endDate: string | Date;
}>;
}
).allocations ?? [];
for (const existing of allocList) {
// Skip the allocation being edited
if (isEditing && allocation && existing.id === allocation.id) continue;
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}
}
return null;
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
}, [
shouldCheckOverlap,
existingAllocations,
startDate,
endDate,
isEditing,
allocation,
resourceId,
]);
const invalidatePlanningViews = useInvalidatePlanningViews();
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
useEffect(() => {
setServerError(null);
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) {
e.preventDefault();
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
// Determine role string from roleId if set
const rolesList = rolesData ?? [];
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));
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
updateMutation.mutate({
id: getPlanningEntryMutationId(allocation),
data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
resourceId: isDemandEntry ? undefined : resourceId || undefined,
projectId,
role: roleString,
roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1,
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
...(isDemandEntry && budgetEur
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
: {}),
startDate: start,
endDate: end,
hoursPerDay,
@@ -279,18 +310,22 @@ 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";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
const resourceList = (resources?.resources ?? []) as Array<{
id: 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 entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return (
<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 */}
<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">
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{isDemandEntry && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Headcount:
</label>
<input
type="number"
value={headcount}
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Budget (EUR):
</label>
<input
type="number"
value={budgetEur}
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{!isDemandEntry && (
<div>
<label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
Resource <span className="text-red-500">*</span>
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
</label>
<select
id="modal-resource"
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Project */}
<div>
<label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
Project <span className="text-red-500">*</span>
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
</label>
<select
id="modal-project"
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Role */}
<div>
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
<label htmlFor="modal-role" className={labelClass}>
Role
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
</label>
<select
id="modal-role"
value={roleId}
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Dates */}
<div>
<div className="flex items-center justify-between mb-1">
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
<span className={labelClass}>
Date Range <span className="text-red-500">*</span>
</span>
<DateRangePresets
onSelect={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-start" className={labelClass}>
Start Date{" "}
<InfoTooltip content="First day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-start"
value={startDate}
onChange={setStartDate}
className={inputClass}
required
/>
</div>
<div>
<label htmlFor="modal-end" className={labelClass}>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
</label>
<DateInput
id="modal-end"
value={endDate}
onChange={setEndDate}
min={startDate}
className={inputClass}
required
/>
</div>
</div>
</div>
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="modal-hours" className={labelClass}>
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
Hours / Day
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
</label>
<input
id="modal-hours"
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div>
<div>
<label htmlFor="modal-status" className={labelClass}>
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
Status
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
</label>
<select
id="modal-status"
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<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." />
<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." />
</label>
{isRecurring && (
<div className="mt-2">
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
)}
{!conflictResult && checkingConflicts && (
<ConflictWarningPanel
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
result={{
isOverbooking: false,
overbooking: null,
vacationOverlap: [],
hasVacationOverlap: false,
}}
isLoading={true}
acknowledged={false}
onAcknowledge={() => {}}
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<button
type="submit"
disabled={isPending || hasUnacknowledgedOverbooking}
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
title={
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"
>
{isPending ? "Saving…" : "Save"}
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -13,8 +13,8 @@ import type {
AllocationWithDetails,
ColumnDef,
AllocationStatus,
} from "@capakraken/shared";
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
} from "@nexus/shared";
import { ALLOCATION_COLUMNS } from "@nexus/shared";
import { useSelection } from "~/hooks/useSelection.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
@@ -328,7 +328,7 @@ export function AllocationsClient() {
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
"capakraken:allocations:viewMode",
"nexus:allocations:viewMode",
"grouped",
);
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import type { AllocationConflictCheckResult } from "@capakraken/shared";
import type { AllocationConflictCheckResult } from "@nexus/shared";
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">
<p className="font-semibold text-amber-800 dark:text-amber-300">
Overbooking on {result.overbooking.totalConflictDays} day
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
{result.overbooking.maxOverbookPercent}% over capacity)
</p>
<p className="mt-1 text-amber-700 dark:text-amber-400">
The resource already has allocations that exceed their daily capacity on the following days.
You can still save check the box below to confirm.
The resource already has allocations that exceed their daily capacity on the following
days. You can still save check the box below to confirm.
</p>
{/* Day-by-day table */}
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
</thead>
<tbody>
{visibleDays.map((day) => (
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
<tr
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 text-right">{day.availableHours}h</td>
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
onClick={() => setShowAllDays((v) => !v)}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
>
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
{showAllDays
? "Show less"
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
</button>
)}
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
</p>
<ul className="mt-2 space-y-1">
{result.vacationOverlap.map((v, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
<li
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="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
<span className="font-medium capitalize">
{v.type.replace(/_/g, " ").toLowerCase()}
</span>
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}</span>
<span>
{v.startDate === v.endDate ? v.startDate : `${v.startDate} ${v.endDate}`}
</span>
</li>
))}
</ul>
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { AllocationStatus } from "@nexus/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const { data: resources } = trpc.resource.listStaff.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
{ staleTime: 15_000 },
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
) as {
data:
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
| undefined;
};
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
{
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const lcrCents = selectedResource.lcrCents ?? 0;
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
setPlanned((prev) => [...prev, {
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
}]);
setPlanned((prev) => [
...prev,
{
resourceId: selectedResource.id,
resourceName: selectedResource.displayName,
eid: selectedResource.eid,
hoursPerDay,
availableHours: avail.totalAvailableHours,
availableDays: avail.availableDays,
conflictDays: avail.conflictDays,
coveragePercent: avail.coveragePercent,
estimatedCostCents,
},
]);
// Reset for next resource
setResourceId("");
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
status: AllocationStatus.PROPOSED,
});
} catch (err) {
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
setServerError(
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
);
setSubmitting(false);
return;
}
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
onClick={(e) => {
if (e.target === e.currentTarget && !submitting) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
onKeyDown={(e) => {
if (e.key === "Escape" && !submitting) onClose();
}}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{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." />
</h2>
<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>
<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 className="px-6 pt-4 pb-2 space-y-3">
{/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
<div
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: roleColor }}
/>
<div className="flex-1">
<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">
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {formatDateMedium(allocation.endDate)}
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {" "}
{formatDateMedium(allocation.endDate)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
{allocation.budgetCents && allocation.budgetCents > 0
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
: ""}
</div>
</div>
</div>
@@ -213,7 +239,10 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<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">
<span>Demand coverage</span>
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
<span>
{Math.round(consumedHours)}h / {totalDemandHours}h (
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
</span>
</div>
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{planned.map((r, i) => (
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="mt-2 space-y-1">
{planned.map((r, i) => (
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
<div
className="w-2 h-2 rounded-full flex-shrink-0"
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-500">{r.hoursPerDay}h/day</span>
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
<span className="ml-auto text-gray-500">
{Math.round(r.availableHours)}h · {r.coveragePercent}%
</span>
{phase === "plan" && (
<button
type="button"
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && (
<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" />
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
<span className="text-amber-600 dark:text-amber-400 font-medium">
Remaining: {Math.round(remainingHours)}h
</span>
</div>
)}
</div>
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" && (
<div className="px-6 pb-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input
type="text"
placeholder="Search by name or EID..."
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Resource
</label>
<select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours / Day
</label>
<input
type="number"
value={hoursPerDay}
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{/* Availability preview */}
{resourceId && avail && (
<div className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "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"
}`}>
<div
className={`rounded-lg p-3 border text-sm ${
avail.coveragePercent >= 100
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: avail.coveragePercent >= 50
? "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"
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
Availability: {avail.resource.name}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-gray-500 dark:text-gray-400">Available</span>
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
<div className="font-semibold text-green-700 dark:text-green-400">
{avail.availableDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
<div className="font-semibold text-red-700 dark:text-red-400">
{avail.conflictDays} days
</div>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Hours</span>
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
<div className="font-semibold text-gray-900 dark:text-gray-100">
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
</div>
</div>
</div>
{avail.existingAssignments.length > 0 && (
<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">Existing bookings:</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
Existing bookings:
</div>
{avail.existingAssignments.slice(0, 4).map((a, i) => (
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
{a.code} · {a.hoursPerDay}h/day · {a.start} {a.end}
</div>
))}
{avail.existingAssignments.length > 4 && (
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
<div className="text-xs text-gray-400">
+{avail.existingAssignments.length - 4} more
</div>
)}
</div>
)}
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
)}
{resourceId && availabilityQuery.isLoading && (
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
Checking availability...
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<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">
<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"
>
Cancel
</button>
<div className="flex items-center gap-2">
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</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>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
Resource
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
h/day
</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>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{r.resourceName}
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</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 text-gray-600 dark:text-gray-300">
{r.hoursPerDay}h
</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">
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
<span
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
>
{r.coveragePercent}%
</span>
</td>
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900">
<tr>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
Total
</td>
<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">
{Math.round(consumedHours)}h / {totalDemandHours}h
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
{totalDemandHours > 0
? Math.round((consumedHours / totalDemandHours) * 100)
: 0}
%
</td>
</tr>
{allocation.budgetCents && allocation.budgetCents > 0 && (
<tr>
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
<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">
{formatCents(allocation.budgetCents)} EUR
</td>
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
const remain = allocation.budgetCents! - totalCost;
return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
<span
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
>
{remain < 0
? `${formatCents(Math.abs(remain))} over`
: `${formatCents(remain)} left`}
</span>
);
})()}
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{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">
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
partially.
</div>
)}
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
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"
>
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
{submitting
? `Assigning ${submitProgress}/${planned.length}...`
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails } from "@capakraken/shared";
import type { AllocationWithDetails } from "@nexus/shared";
type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string;
@@ -1,7 +1,7 @@
"use client";
import { RecurrenceFrequency } from "@capakraken/shared";
import type { RecurrencePattern } from "@capakraken/shared";
import { RecurrenceFrequency } from "@nexus/shared";
import type { RecurrencePattern } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -39,7 +39,10 @@ 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">
{/* Frequency selector */}
<div>
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
<span className={labelClass}>
Frequency
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
</span>
<div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => (
<button
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{f === RecurrenceFrequency.WEEKLY
? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
? "Biweekly"
: f === RecurrenceFrequency.MONTHLY
? "Monthly"
: "Custom"}
</button>
))}
</div>
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div>
<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>
<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>
<div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow);
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && (
<div>
<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>
<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>
<input
type="number"
min={0.5}
@@ -71,8 +71,8 @@ interface AssistantInsight {
sections?: AssistantInsightSection[];
}
const STORAGE_KEY = "capakraken-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
const STORAGE_KEY = "nexus-chat-messages";
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false;
@@ -1,8 +1,8 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
import { FieldCard } from "./FieldCard.js";
@@ -48,10 +48,7 @@ interface FieldState {
// 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);
if (catalogField) {
return {
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
// 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> = {};
// Start with all catalog fields disabled
for (const cf of catalog) {
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
// Handlers
// ---------------------------------------------------------------------------
const handleCatalogFieldChange = useCallback(
(key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
},
[],
);
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
}, []);
const handleCustomFieldChange = useCallback(
(idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) =>
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
);
},
[],
);
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
}, []);
function removeCustomField(idx: number) {
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
// Collapsed categories
// ---------------------------------------------------------------------------
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
new Set(),
);
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
function toggleCategory(name: string) {
setCollapsedCategories((prev) => {
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
{/* Field cards */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{categories
.filter(
(cat) =>
activeCategory === null ||
activeCategory === cat.name,
)
.filter((cat) => activeCategory === null || activeCategory === cat.name)
.map((cat) => {
const fields = fieldsByCategory.get(cat.name) ?? [];
if (fields.length === 0 && searchQuery.trim()) return null;
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
if (
fields.length === 0 &&
activeCategory !== null &&
activeCategory !== cat.name
)
return null;
const isCollapsed = collapsedCategories.has(cat.name);
@@ -527,9 +513,7 @@ export function BlueprintFieldCatalog({
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
{cat.name}
</h3>
<span className="text-xs text-gray-400">
{cat.description}
</span>
<span className="text-xs text-gray-400">{cat.description}</span>
</button>
{!isCollapsed && (
<div className="grid grid-cols-1 gap-2">
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
key={field.key}
field={field}
overrides={catalogOverrides[field.key]!}
onChange={(ov) =>
handleCatalogFieldChange(field.key, ov)
}
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
/>
))}
{fields.length === 0 && (
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
})}
{/* Custom Fields section */}
{(activeCategory === null ||
activeCategory === "Custom Fields") && (
{(activeCategory === null || activeCategory === "Custom Fields") && (
<div>
<button
type="button"
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
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">
{collapsedCategories.has("Custom Fields")
? "\u25B6"
: "\u25BC"}
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Custom Fields
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
label: cf.custom.label,
type: cf.custom.type,
category: "Custom Fields",
description:
cf.overrides.description || "Custom field",
description: cf.overrides.description || "Custom field",
...(cf.custom.options.length > 0
? { options: cf.custom.options }
: {}),
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
<FieldCard
field={pseudoCatalog}
overrides={cf.overrides}
onChange={(ov) =>
handleCustomFieldChange(idx, ov)
}
onChange={(ov) => handleCustomFieldChange(idx, ov)}
/>
<button
type="button"
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Key{" "}
<span className="text-red-500">*</span>
Key <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customKey}
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"
className="app-input font-mono"
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600">
Label{" "}
<span className="text-red-500">*</span>
Label <span className="text-red-500">*</span>
</label>
<input
type="text"
value={customLabel}
onChange={(e) =>
setCustomLabel(e.target.value)
}
onChange={(e) => setCustomLabel(e.target.value)}
placeholder="Display Label"
className="app-input"
/>
</div>
<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
value={customType}
onChange={(e) =>
setCustomType(
e.target.value as FieldType,
)
}
onChange={(e) => setCustomType(e.target.value as FieldType)}
className="app-input"
>
{FIELD_TYPES.map((ft) => (
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
<button
type="button"
onClick={addCustomField}
disabled={
!customKey.trim() || !customLabel.trim()
}
disabled={!customKey.trim() || !customLabel.trim()}
className={BTN_PRIMARY}
>
Add
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
onClick={() => setShowCustomForm(true)}
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>
)}
</div>
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
{/* 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">
<span className="text-xs text-gray-400">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
saved
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
</span>
<div className="flex items-center gap-3">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
) : (
<div className="px-6 py-4 overflow-y-auto">
<p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation
Wizard when this blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js";
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
}
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);
}
@@ -111,8 +109,7 @@ interface FieldRowProps {
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
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>(
key: K,
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
{/* Main row */}
<div className="flex flex-wrap items-center gap-2">
{/* 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 */}
<input
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
// Clear options when switching away from select types
const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? []
? (field.options ?? [])
: undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}}
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
/>
</div>
<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
type="text"
value={field.placeholder ?? ""}
onChange={(e) =>
update("placeholder", e.target.value || undefined)
}
onChange={(e) => update("placeholder", e.target.value || undefined)}
placeholder="Placeholder text"
className="app-input"
/>
</div>
<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
type="text"
value={field.description ?? ""}
onChange={(e) =>
update("description", e.target.value || undefined)
}
onChange={(e) => update("description", e.target.value || undefined)}
placeholder="Helper text"
className="app-input"
/>
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
[...initialFieldDefs].sort((a, b) => a.order - b.order),
);
const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
}
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) {
setFields((prev) =>
prev.map((f, i) => (i === idx ? updated : f)),
);
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
}
function handleSave() {
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
{/* Header */}
<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">
Edit Fields:{" "}
<span className="text-gray-600 font-normal">{blueprintName}</span>
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
</h2>
<button
type="button"
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
) : (
<div className="px-6 py-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 blueprint is selected.
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p>
<RolePresetsEditor
initialPresets={initialRolePresets}
@@ -2,8 +2,8 @@
import { useState, useEffect } from "react";
import type { FormEvent } from "react";
import type { BlueprintTarget } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import type { BlueprintTarget } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
import { useSelection } from "~/hooks/useSelection.js";
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
}
initialRolePresets={
Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
: []
}
initialTab={editingTab}

Some files were not shown because too many files have changed in this diff Show More