13 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
972 changed files with 25086 additions and 17066 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. # Copy this file to .env and fill in the values before running the app.
# Lines starting with # are comments. Lines with no value are optional. # Lines starting with # are comments. Lines with no value are optional.
@@ -12,7 +12,7 @@
# REQUIRED — Public URL of the app (with scheme, no trailing slash). # REQUIRED — Public URL of the app (with scheme, no trailing slash).
# Used in email links (invites, password reset) and as the Auth.js base URL. # Used in email links (invites, password reset) and as the Auth.js base URL.
# Must use https:// in production. # Must use https:// in production.
NEXTAUTH_URL=https://capakraken.example.com NEXTAUTH_URL=https://nexus.example.com
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies. # REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
# Generate one with: openssl rand -base64 32 # Generate one with: openssl rand -base64 32
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app # host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
# container this variable is overridden by docker-compose.yml (which routes # container this variable is overridden by docker-compose.yml (which routes
# to the postgres service name on the internal network). # 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 ─────────────────────────────────────────────────────────────────── # ─── Redis ───────────────────────────────────────────────────────────────────
@@ -65,7 +65,7 @@ REDIS_PASSWORD=
# SMTP_PORT=587 # SMTP_PORT=587
# SMTP_USER=no-reply@example.com # SMTP_USER=no-reply@example.com
# SMTP_PASSWORD= # SMTP_PASSWORD=
# SMTP_FROM=CapaKraken <no-reply@example.com> # SMTP_FROM=Nexus <no-reply@example.com>
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain # SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
# ─── pgAdmin (dev / Docker Compose only) ───────────────────────────────────── # ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
@@ -74,8 +74,8 @@ REDIS_PASSWORD=
# Used as the password for the pgAdmin web UI (http://localhost:5050). # Used as the password for the pgAdmin web UI (http://localhost:5050).
PGADMIN_PASSWORD= PGADMIN_PASSWORD=
# Email shown on the pgAdmin login screen (default: admin@capakraken.dev). # Email shown on the pgAdmin login screen (default: admin@nexus.dev).
# PGADMIN_EMAIL=admin@capakraken.dev # PGADMIN_EMAIL=admin@nexus.dev
# ─── Logging ───────────────────────────────────────────────────────────────── # ─── Logging ─────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
# that any resolved path remains inside this directory; this prevents an # that any resolved path remains inside this directory; this prevents an
# admin (or compromised admin token) from pointing the parser at arbitrary # admin (or compromised admin token) from pointing the parser at arbitrary
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset. # 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) ──────────────────────────────────── # ─── 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 ## Repo-Secrets für CI/CD
Im capakraken-Repo → **Settings → Actions → Secrets** eintragen: Im nexus-Repo → **Settings → Actions → Secrets** eintragen:
| Secret | Zweck | | Secret | Zweck |
| ----------------------- | -------------------------------------- | | ----------------------- | -------------------------------------- |
+1 -1
View File
@@ -2,7 +2,7 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a security vulnerability in 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. **Do not** open a public GitHub issue for security vulnerabilities.
+43 -39
View File
@@ -114,7 +114,7 @@ jobs:
run: pnpm db:generate run: pnpm db:generate
- name: Run assistant split regression - name: Run assistant split regression
run: pnpm --filter @capakraken/api test:assistant-split run: pnpm --filter @nexus/api test:assistant-split
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Lint — ~20s, no services needed # Lint — ~20s, no services needed
@@ -159,11 +159,11 @@ jobs:
postgres: postgres:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_DB: capakraken_test POSTGRES_DB: nexus_test
POSTGRES_USER: capakraken POSTGRES_USER: nexus
POSTGRES_PASSWORD: capakraken_test POSTGRES_PASSWORD: nexus_test
options: >- options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test" --health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
@@ -175,7 +175,7 @@ jobs:
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
env: 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 REDIS_URL: redis://redis:6379
# Force in-memory rate limiter to avoid cross-test state when Redis drops. # 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. # Redis fallback downgrades to max/10 limits which rate-limits unit tests.
@@ -204,13 +204,13 @@ jobs:
- name: Run unit tests with coverage - name: Run unit tests with coverage
run: | run: |
pnpm --filter @capakraken/web test:unit -- --coverage pnpm --filter @nexus/web test:unit -- --coverage
pnpm --filter @capakraken/engine exec vitest run --coverage pnpm --filter @nexus/engine exec vitest run --coverage
pnpm --filter @capakraken/staffing exec vitest run --coverage pnpm --filter @nexus/staffing exec vitest run --coverage
pnpm --filter @capakraken/api exec vitest run --coverage pnpm --filter @nexus/api exec vitest run --coverage
pnpm --filter @capakraken/application exec vitest run --coverage pnpm --filter @nexus/application exec vitest run --coverage
pnpm --filter @capakraken/shared exec vitest run --coverage pnpm --filter @nexus/shared exec vitest run --coverage
pnpm --filter @capakraken/db test:unit pnpm --filter @nexus/db test:unit
- name: Upload coverage reports - name: Upload coverage reports
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -274,7 +274,7 @@ jobs:
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}- restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Build - name: Build
run: pnpm --filter @capakraken/web exec next build run: pnpm --filter @nexus/web exec next build
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# E2E — depends on build, needs PostgreSQL + Redis # E2E — depends on build, needs PostgreSQL + Redis
@@ -291,11 +291,11 @@ jobs:
e2epg: e2epg:
image: postgres:16 image: postgres:16
env: env:
POSTGRES_DB: capakraken_test POSTGRES_DB: nexus_test
POSTGRES_USER: capakraken POSTGRES_USER: nexus
POSTGRES_PASSWORD: capakraken_test POSTGRES_PASSWORD: nexus_test
options: >- options: >-
--health-cmd="pg_isready -U capakraken -d capakraken_test" --health-cmd="pg_isready -U nexus -d nexus_test"
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
@@ -307,14 +307,14 @@ jobs:
--health-timeout=5s --health-timeout=5s
--health-retries=5 --health-retries=5
env: 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 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 # prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
# the expected target; default is "capakraken", CI uses capakraken_test. # the expected target; default is "nexus", CI uses nexus_test.
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test NEXUS_EXPECTED_DB_NAME: nexus_test
ALLOW_DESTRUCTIVE_DB_TOOLS: "true" ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
REDIS_URL: redis://e2eredis:6379 REDIS_URL: redis://e2eredis:6379
PORT: 3100 PORT: 3100
# test-server.mjs spawns `docker compose --profile test up postgres-test`; # test-server.mjs spawns `docker compose --profile test up postgres-test`;
@@ -364,18 +364,18 @@ jobs:
- name: Install Playwright browsers - name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm --filter @capakraken/web exec playwright install --with-deps chromium run: pnpm --filter @nexus/web exec playwright install --with-deps chromium
- name: Install Playwright system deps - name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true' if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm --filter @capakraken/web exec playwright install-deps chromium run: pnpm --filter @nexus/web exec playwright install-deps chromium
- name: Install psql (debug schema state) - name: Install psql (debug schema state)
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
- name: Push DB schema & seed - name: Push DB schema & seed
env: env:
PGPASSWORD: capakraken_test PGPASSWORD: nexus_test
run: | run: |
# Nuke any leftover schema state from a previous job that shared the # Nuke any leftover schema state from a previous job that shared the
# postgres service container (act_runner reuses service volumes). # postgres service container (act_runner reuses service volumes).
@@ -397,7 +397,7 @@ jobs:
IPS=$(getent hosts e2epg | awk '{print $1}') IPS=$(getent hosts e2epg | awk '{print $1}')
PG_IP="" PG_IP=""
for ip in $IPS; do 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" PG_IP="$ip"
echo "Locked onto postgres at $PG_IP" echo "Locked onto postgres at $PG_IP"
break break
@@ -406,19 +406,19 @@ jobs:
fi fi
done done
if [ -z "$PG_IP" ]; then 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 exit 1
fi 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 "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV" echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
echo "--- DROP SCHEMA ---" echo "--- DROP SCHEMA ---"
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \ 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 capakraken; GRANT ALL ON SCHEMA public TO public;" -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 ---" 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 ---" 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" \ -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
| tee /tmp/tables.txt | tee /tmp/tables.txt
if ! grep -qx 'audit_logs' /tmp/tables.txt; then if ! grep -qx 'audit_logs' /tmp/tables.txt; then
@@ -438,7 +438,7 @@ jobs:
# and restarts mid-run, producing cascading ECONNREFUSED failures # and restarts mid-run, producing cascading ECONNREFUSED failures
# unrelated to test content. Scope CI to smoke.spec.ts; full suite # unrelated to test content. Scope CI to smoke.spec.ts; full suite
# is run locally / in a dedicated nightly job. # is run locally / in a dedicated nightly job.
run: pnpm --filter @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 - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -468,8 +468,8 @@ jobs:
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
PGADMIN_PASSWORD=ci-pgadmin PGADMIN_PASSWORD=ci-pgadmin
# Must match the password baked into docker-compose.ci.yml's # Must match the password baked into docker-compose.ci.yml's
# DATABASE_URL override (capakraken_dev). # DATABASE_URL override (nexus_dev).
POSTGRES_PASSWORD=capakraken_dev POSTGRES_PASSWORD=nexus_dev
EOF EOF
- name: Tear down any stale stack & volumes - name: Tear down any stale stack & volumes
@@ -477,7 +477,11 @@ jobs:
# runs. A previous run's failed migration entry in _prisma_migrations # runs. A previous run's failed migration entry in _prisma_migrations
# causes P3009 on the next migrate deploy; wipe volumes for a truly # causes P3009 on the next migrate deploy; wipe volumes for a truly
# fresh deploy test every time. # 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) - name: Start infrastructure (postgres + redis)
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d 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 - name: Wait for postgres
run: | run: |
for i in $(seq 1 20); do 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 sleep 3
done 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/@prisma /app/scripts/node_modules/@prisma
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
node /app/scripts/setup-admin.mjs --email admin@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 - name: Set up Node.js 20
+4 -4
View File
@@ -1,8 +1,8 @@
# CapaKraken # Nexus
## Ziel ## 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 ## Tech Stack
@@ -19,7 +19,7 @@ CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-P
## Monorepo-Struktur ## Monorepo-Struktur
```text ```text
capakraken/ nexus/
├── apps/web ├── apps/web
├── packages/shared ├── packages/shared
├── packages/db ├── packages/db
@@ -41,7 +41,7 @@ capakraken/
## Quality Gates ## Quality Gates
- `pnpm test:unit` - `pnpm test:unit`
- `pnpm --filter @capakraken/web exec tsc --noEmit` - `pnpm --filter @nexus/web exec tsc --noEmit`
- `pnpm lint` - `pnpm lint`
## Dokumente ## Dokumente
+1 -1
View File
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
COPY . . COPY . .
# Generate Prisma client # Generate Prisma client
RUN pnpm --filter @capakraken/db db:generate RUN pnpm --filter @nexus/db db:generate
EXPOSE 3100 EXPOSE 3100
+3 -3
View File
@@ -39,7 +39,7 @@ COPY --from=deps /app/ ./
COPY . . COPY . .
# Generate Prisma client # Generate Prisma client
RUN pnpm --filter @capakraken/db db:generate RUN pnpm --filter @nexus/db db:generate
# Build the Next.js application # Build the Next.js application
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -63,7 +63,7 @@ RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
AUTH_SECRET="$AUTH_SECRET" \ AUTH_SECRET="$AUTH_SECRET" \
DATABASE_URL="$DATABASE_URL" \ DATABASE_URL="$DATABASE_URL" \
REDIS_URL="$REDIS_URL" \ REDIS_URL="$REDIS_URL" \
pnpm --filter @capakraken/web build pnpm --filter @nexus/web build
# ============================================================ # ============================================================
# Stage 3: Migration runner # Stage 3: Migration runner
@@ -72,7 +72,7 @@ FROM builder AS migrator
ENV NODE_ENV=production ENV NODE_ENV=production
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"] CMD ["pnpm", "--filter", "@nexus/db", "db:migrate:deploy"]
# ============================================================ # ============================================================
# Stage 4: Production runtime # Stage 4: Production runtime
+69 -18
View File
@@ -1,6 +1,7 @@
# CapaKraken Projekt-Learnings # Nexus Projekt-Learnings
## Format ## Format
**Datum | Kategorie | Problem → Lösung** **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. **Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
**Solution — split config pattern:** **Solution — split config pattern:**
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2 - `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
- `auth-edge.ts``NextAuth(authConfig)` with the lean config; used only by middleware - `auth-edge.ts``NextAuth(authConfig)` with the lean config; used only by middleware
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking - `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
**Middleware wrapping:** **Middleware wrapping:**
```ts ```ts
import { auth } from "./server/auth-edge.js"; import { auth } from "./server/auth-edge.js";
export default auth(function middleware(request) { export default auth(function middleware(request) {
@@ -28,17 +31,19 @@ export default auth(function middleware(request) {
``` ```
**Three-layer defence:** **Three-layer defence:**
1. Middleware — server-side redirect before page renders 1. Middleware — server-side redirect before page renders
2. `SessionGuard` client component — `useSession()``router.replace()` on SPA navigation 2. `SessionGuard` client component — `useSession()``router.replace()` on SPA navigation
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()` 3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
**Test mock pattern for middleware tests:** **Test mock pattern for middleware tests:**
```ts ```ts
vi.mock("./server/auth-edge.js", () => ({ vi.mock("./server/auth-edge.js", () => ({
auth: (handler) => (req) => auth: (handler) => (req) => handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
})); }));
``` ```
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope. Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
--- ---
@@ -50,12 +55,14 @@ Needed because `vi.resetModules()` inside the helper function doesn't re-apply t
**Repo path:** `Hartmut/plANARCHY` **Repo path:** `Hartmut/plANARCHY`
Usage example (list open issues): Usage example (list open issues):
```bash ```bash
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \ curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50" "https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
``` ```
Close an issue with a comment: Close an issue with a comment:
```bash ```bash
TOKEN=$(cat ~/.gitea-token) TOKEN=$(cat ~/.gitea-token)
REPO="Hartmut/plANARCHY" REPO="Hartmut/plANARCHY"
@@ -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. **Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
**Solution:** Always restart the app container after Prisma schema changes: **Solution:** Always restart the app container after Prisma schema changes:
``` ```
docker compose --profile full restart app docker compose --profile full restart app
``` ```
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed. The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
**Rule:** Prisma schema change checklist: **Rule:** Prisma schema change checklist:
1. Edit `packages/db/prisma/schema.prisma` 1. Edit `packages/db/prisma/schema.prisma`
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql` 2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
3. Apply migration to the running DB directly (for dev speed): `docker exec 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 4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design ### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments. - Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned). - `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`. - Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
@@ -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. - React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files ### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state. - `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow. - Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields). - For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
### 2026-03-12 | Build | NextAuth portable export typing ### 2026-03-12 | Build | NextAuth portable export typing
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`. - `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing. - If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction ### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services - Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events - `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
- `packages/application` depends on `@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) - Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases - `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces - `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes - `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
### 2026-03-12 | Architecture | Dashboard query extraction into application layer ### 2026-03-12 | Architecture | Dashboard query extraction into application layer
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`. - Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services. - Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices. - Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
@@ -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. - While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
### 2026-03-12 | Architecture | Estimating foundation slice ### 2026-03-12 | Architecture | Estimating foundation slice
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports. - Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model. - Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
- Shared estimate enums/types/schemas now live in `@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. - Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder ### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error. - Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge. - Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function. - Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
@@ -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. - CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22) ### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues. - Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead. - `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs. - Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access. - `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
### 2026-03-11 | Security | Phase 0 critical fixes ### 2026-03-11 | Security | Phase 0 critical fixes
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution.
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@nexus/db` is not enough for TS resolution.
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`. - `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI. - `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
- `@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. - `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation ### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects. **Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation. **Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`. **Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern ### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries. **Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total. **Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
### 2026-03-09 | Performance | recomputeValueScores sequential updates ### 2026-03-09 | Performance | recomputeValueScores sequential updates
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop. **Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip. **Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create ### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`. **Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
**Fix:** Use `ctx.dbUser?.id` directly. **Fix:** Use `ctx.dbUser?.id` directly.
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents ### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing. `AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array ### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list. `trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern ### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel. For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell ### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war. **Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten. **Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten. **Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
@@ -180,6 +207,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen ### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert. **Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause. **Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren TypeScript erzwingt das ohnehin. **Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren TypeScript erzwingt das ohnehin.
@@ -187,6 +215,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder ### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten. **Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`). **Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen. **Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
@@ -194,19 +223,22 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes ### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt. **Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`. **Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
--- ---
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert ### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root. **Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/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. **Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
--- ---
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline ### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode). **Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt. **Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben. **Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
@@ -214,6 +246,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung ### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert. **Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`. **Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt. **Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
@@ -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 ### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` tRPC leitet Typen rekursiv ab. **Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` tRPC leitet Typen rekursiv ab.
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem. **Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern. **Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
@@ -228,8 +262,10 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum ### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind.
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@nexus/shared`-Enums kompatibel sind.
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten: **Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
- `project as unknown as Project` - `project as unknown as Project`
- `form.orderType as unknown as OrderType` - `form.orderType as unknown as OrderType`
- `resource.skills as unknown as SkillEntry[]` - `resource.skills as unknown as SkillEntry[]`
@@ -237,6 +273,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
--- ---
### 2026-02-xx | Architektur | SSE statt WebSockets ### 2026-02-xx | Architektur | SSE statt WebSockets
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket. **Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies. **Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations. **Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
@@ -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. **Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen schwer wartbar, kaum testbar.
**Lösung:** Schrittweise Extraktion: **Lösung:** Schrittweise Extraktion:
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit) 1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
2. Heatmap-Utilities → `heatmapUtils.ts` 2. Heatmap-Utilities → `heatmapUtils.ts`
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook 3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
4. Header-JSX → `TimelineHeader.tsx` 4. Header-JSX → `TimelineHeader.tsx`
5. Toolbar-JSX → `TimelineToolbar.tsx` 5. Toolbar-JSX → `TimelineToolbar.tsx`
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler. **Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt. **Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
--- ---
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE ### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen. **Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
**Lösung:** `ioredis` in `@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. **Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) notwendig mit `moduleResolution: NodeNext` + ioredis v5.
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose. **Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
@@ -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. **Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix. **Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen: **Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
```typescript ```typescript
const adminHash = await hash("admin123"); const adminHash = await hash("admin123");
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } }); prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
``` ```
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen. **Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
--- ---
@@ -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. **Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug. **Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
**Lösung (zweistufig):** **Lösung (zweistufig):**
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App. 1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall). 2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout. **Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist. **Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
--- ---
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster ### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
**Kontext:** 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: **Lösung:** Zweigeteiltes System:
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys. 1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen. 2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an. 3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
@@ -421,6 +463,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
--- ---
## Offene Fragen ## Offene Fragen
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub) - [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects) - [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
- [x] P7.2 Touch-Support → umgesetzt - [x] P7.2 Touch-Support → umgesetzt
@@ -430,6 +473,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
--- ---
### 2026-03-07 | Architektur | Resource Value Score kontext-freie Composite-Metrik ### 2026-03-07 | Architektur | Resource Value Score kontext-freie Composite-Metrik
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung. **Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet). **Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar. **Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
@@ -439,14 +483,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
--- ---
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten ### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz. **Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand. **Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`). **Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
**Merkregel:** `db:push``.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen). **Merkregel:** `db:push``.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State ### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user. **Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
**Lösung:** **Lösung:**
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst. - **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set). - **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort. - **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
@@ -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. - **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization ### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid. **Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
**Lösung:** **Lösung:**
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values. - **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting. - **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them. - **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog. - **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe. - **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation. - **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers. **TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision ### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
@@ -474,11 +523,13 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
**Decision: No code is written until the product decision is made.** **Decision: No code is written until the product decision is made.**
The core trade-off is: The core trade-off is:
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface. - **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden. - **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
- **Short-lived API tokens (OAuth-style):** Suitable if 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:** **Engineering guidance for when the decision is made:**
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token. 1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
2. Enforce per-key scopes aligned with the `SystemRole` permission model. 2. Enforce per-key scopes aligned with the `SystemRole` permission model.
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row. 3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
+98 -98
View File
@@ -1,8 +1,8 @@
<p align="center"> <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> </p>
<h1 align="center">CapaKraken</h1> <h1 align="center">Nexus</h1>
<p align="center"> <p align="center">
<strong>Resource &amp; Capacity Planning for 3D Production Studios</strong><br/> <strong>Resource &amp; Capacity Planning for 3D Production Studios</strong><br/>
@@ -25,7 +25,7 @@
## About ## About
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. The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
@@ -39,7 +39,7 @@ The application was designed from the ground up for the unique challenges of cre
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" /> <img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
</p> </p>
The timeline is the centerpiece of 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 - **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
- **Project View** -- flip the perspective to see all resources assigned to each project - **Project View** -- flip the perspective to see all resources assigned to each project
@@ -97,18 +97,18 @@ A structured list view of all allocations with:
Each user gets a personal dashboard they can customize with drag-and-drop widgets: Each user gets a personal dashboard they can customize with drag-and-drop widgets:
| Widget | Description | | Widget | Description |
|--------|-------------| | -------------------------- | --------------------------------------------------------------------------------- |
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance | | **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
| **My Projects** | Quick access to projects where the current user is assigned or responsible | | **My Projects** | Quick access to projects where the current user is assigned or responsible |
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators | | **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
| **Project Overview** | All projects with cost, person days, and status badges | | **Project Overview** | All projects with cost, person days, and status badges |
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown | | **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking | | **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score | | **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
| **Budget Forecast** | Budget burn rate and projected cost per active project | | **Budget Forecast** | Budget burn rate and projected cost per active project |
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply | | **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
| **Project Health** | Composite health score per project (budget, staffing, timeline) | | **Project Health** | Composite health score per project (budget, staffing, timeline) |
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes. Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
@@ -172,23 +172,23 @@ A full project estimation workflow:
## Tech Stack ## Tech Stack
| Layer | Technology | Purpose | | Layer | Technology | Purpose |
|-------|-----------|---------| | -------------------- | --------------------------------- | -------------------------------------------------------- |
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing | | **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens | | **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server | | **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields | | **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA | | **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity | | **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation | | **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation | | **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests | | **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
| **Containerization** | Docker Compose | Dev and production stacks with health checks | | **Containerization** | Docker Compose | Dev and production stacks with health checks |
### Monorepo Structure ### Monorepo Structure
``` ```
capakraken/ nexus/
| |
+-- apps/ +-- apps/
| +-- web/ Next.js 15 application (frontend + API routes) | +-- web/ Next.js 15 application (frontend + API routes)
@@ -263,18 +263,18 @@ capakraken/
### Prerequisites ### Prerequisites
| Requirement | Minimum Version | Check | | Requirement | Minimum Version | Check |
|-------------|----------------|-------| | ------------------ | --------------- | ------------------------ |
| **Node.js** | 20.x | `node --version` | | **Node.js** | 20.x | `node --version` |
| **pnpm** | 9.x | `pnpm --version` | | **pnpm** | 9.x | `pnpm --version` |
| **Docker** | 24+ | `docker --version` | | **Docker** | 24+ | `docker --version` |
| **Docker Compose** | v2 | `docker compose version` | | **Docker Compose** | v2 | `docker compose version` |
### 1. Clone and configure ### 1. Clone and configure
```bash ```bash
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git capakraken git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git nexus
cd capakraken cd nexus
``` ```
Create your environment file: Create your environment file:
@@ -315,13 +315,13 @@ This single command will:
You'll see output like: You'll see output like:
``` ```
Starting CapaKraken... Starting Nexus...
Starting PostgreSQL + Redis... Starting PostgreSQL + Redis...
Waiting for PostgreSQL... Waiting for PostgreSQL...
Starting app container on port 3100... Starting app container on port 3100...
Waiting for server (up to 90s)... Waiting for server (up to 90s)...
CapaKraken is running! Nexus is running!
{ {
"status": "ok", "status": "ok",
"database": "connected", "database": "connected",
@@ -372,13 +372,13 @@ This populates the database with sample clients, projects, resources, allocation
When running with Docker Compose, the following services are available: When running with Docker Compose, the following services are available:
| Service | URL | Purpose | | Service | URL | Purpose |
|---------|-----|---------| | -------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
| **CapaKraken App** | [localhost:3100](http://localhost:3100) | Main application | | **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) | | **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration | | **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `capakraken`, db: `capakraken`) | | **PostgreSQL** | `localhost:5433` | Direct database access (user: `nexus`, db: `nexus`) |
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub | | **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
--- ---
@@ -386,50 +386,50 @@ When running with Docker Compose, the following services are available:
### Application Lifecycle ### Application Lifecycle
| Command | Description | | Command | Description |
|---------|-------------| | ------------------------- | ------------------------------------------- |
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) | | `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
| `bash scripts/stop.sh` | Stop all services gracefully | | `bash scripts/stop.sh` | Stop all services gracefully |
| `bash scripts/restart.sh` | Full stop + start cycle | | `bash scripts/restart.sh` | Full stop + start cycle |
### Development ### Development
| Command | Description | | Command | Description |
|---------|-------------| | ------------------------- | -------------------------------------------------------- |
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) | | `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
| `pnpm build` | Production build (standalone output) | | `pnpm build` | Production build (standalone output) |
| `pnpm lint` | Run ESLint across all packages | | `pnpm lint` | Run ESLint across all packages |
| `pnpm format` | Format all files with Prettier | | `pnpm format` | Format all files with Prettier |
| `pnpm test:unit` | Run unit tests via Vitest | | `pnpm test:unit` | Run unit tests via Vitest |
| `pnpm test:e2e` | Run end-to-end tests via Playwright | | `pnpm test:e2e` | Run end-to-end tests via Playwright |
| `pnpm typecheck` | TypeScript type checking across all packages | | `pnpm typecheck` | TypeScript type checking across all packages |
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) | | `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
### Database ### Database
| Command | Description | | Command | Description |
|---------|-------------| | --------------------- | ------------------------------------------------ |
| `pnpm db:generate` | Regenerate Prisma client after schema changes | | `pnpm db:generate` | Regenerate Prisma client after schema changes |
| `pnpm db:migrate` | Create and apply new migrations | | `pnpm db:migrate` | Create and apply new migrations |
| `pnpm db:push` | Push schema changes directly (no migration file) | | `pnpm db:push` | Push schema changes directly (no migration file) |
| `pnpm db:studio` | Open Prisma Studio (visual data browser) | | `pnpm db:studio` | Open Prisma Studio (visual data browser) |
| `pnpm db:seed` | Seed the database with demo data | | `pnpm db:seed` | Seed the database with demo data |
| `pnpm db:doctor` | Run health checks on database state | | `pnpm db:doctor` | Run health checks on database state |
| `pnpm db:seed:export` | Export current DB state as a seed file | | `pnpm db:seed:export` | Export current DB state as a seed file |
| `pnpm db:seed:import` | Import a previously exported seed file | | `pnpm db:seed:import` | Import a previously exported seed file |
--- ---
## Production Deployment ## Production Deployment
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 ### Quick Deploy
```bash ```bash
# Configure required secrets # Configure required secrets
export APP_IMAGE=ghcr.io/your-org/capakraken-app:latest export APP_IMAGE=ghcr.io/your-org/nexus-app:latest
export MIGRATOR_IMAGE=ghcr.io/your-org/capakraken-migrator:latest export MIGRATOR_IMAGE=ghcr.io/your-org/nexus-migrator:latest
export POSTGRES_PASSWORD=$(openssl rand -hex 32) export POSTGRES_PASSWORD=$(openssl rand -hex 32)
export REDIS_PASSWORD=$(openssl rand -hex 32) export REDIS_PASSWORD=$(openssl rand -hex 32)
export NEXTAUTH_SECRET=$(openssl rand -base64 32) export NEXTAUTH_SECRET=$(openssl rand -base64 32)
@@ -454,35 +454,35 @@ bash tooling/deploy/deploy-compose.sh production
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables: See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| | ---------------------- | -------- | -------------------- | ----------------------------------------------- |
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application | | `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption | | `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string | | `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password | | `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) | | `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
| `SMTP_HOST` | No | -- | SMTP server for email delivery | | `SMTP_HOST` | No | -- | SMTP server for email delivery |
| `SMTP_PORT` | No | `587` | SMTP port | | `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_FROM` | No | `noreply@capakraken.dev` | Sender address for outgoing emails | | `SMTP_FROM` | No | `noreply@nexus.dev` | Sender address for outgoing emails |
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features | | `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
| `GEMINI_API_KEY` | No | -- | Alternative AI provider | | `GEMINI_API_KEY` | No | -- | Alternative AI provider |
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) | | `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints | | `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
--- ---
## Design Principles ## Design Principles
| Principle | Implementation | | Principle | Implementation |
|-----------|---------------| | ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift | | **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries | | **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports | | **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results | | **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling | | **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode | | **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries | | **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction | | **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
--- ---
@@ -494,7 +494,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
4. Run quality gates before submitting: 4. Run quality gates before submitting:
```bash ```bash
pnpm test:unit pnpm test:unit
pnpm --filter @capakraken/web exec tsc --noEmit pnpm --filter @nexus/web exec tsc --noEmit
pnpm lint pnpm lint
pnpm check:architecture pnpm check:architecture
``` ```
+1 -1
View File
@@ -3,7 +3,7 @@ import { test, expect } from "./a11y-fixture.js";
test.describe("Accessibility (axe-core)", () => { test.describe("Accessibility (axe-core)", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 }); await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
+17 -14
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Admin Pages", () => { test.describe("Admin Pages", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -12,39 +12,42 @@ test.describe("Admin Pages", () => {
test("settings page loads", async ({ page }) => { test("settings page loads", async ({ page }) => {
await page.goto("/admin/settings"); await page.goto("/admin/settings");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 }); await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({
timeout: 10000,
});
}); });
test("users page loads with user list", async ({ page }) => { test("users page loads with user list", async ({ page }) => {
await page.goto("/admin/users"); await page.goto("/admin/users");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 }); await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({
timeout: 10000,
});
// Should show a table with at least the admin user // Should show a table with at least the admin user
await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
await expect(page.locator("text=admin@capakraken.dev")).toBeVisible({ timeout: 10000 }); await expect(page.locator("text=admin@nexus.dev")).toBeVisible({ timeout: 10000 });
}); });
test("roles page loads", async ({ page }) => { test("roles page loads", async ({ page }) => {
await page.goto("/roles"); await page.goto("/roles");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1").filter({ hasText: /Roles/i })).toBeVisible({ timeout: 10000 });
page.locator("h1").filter({ hasText: /Roles/i }),
).toBeVisible({ timeout: 10000 });
// Should show table or list of roles // Should show table or list of roles
await expect( await expect(page.locator("table").or(page.locator("text=No roles"))).toBeVisible({
page.locator("table").or(page.locator("text=No roles")), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("blueprints page loads", async ({ page }) => { test("blueprints page loads", async ({ page }) => {
await page.goto("/admin/blueprints"); await page.goto("/admin/blueprints");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1").filter({ hasText: /Blueprints/i })).toBeVisible({
page.locator("h1").filter({ hasText: /Blueprints/i }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
// Should show blueprint cards or list from seed data // Should show blueprint cards or list from seed data
await expect( await expect(
page.locator("table") page
.locator("table")
.or(page.locator("text=3D Content Production")) .or(page.locator("text=3D Content Production"))
.or(page.locator("text=No blueprints")), .or(page.locator("text=No blueprints")),
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
+22 -16
View File
@@ -40,24 +40,28 @@ async function signIn(page: Page, email: string, password: string) {
test.describe("Allocations", () => { test.describe("Allocations", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await freezeBrowserTime(page); await freezeBrowserTime(page);
await signIn(page, "admin@capakraken.dev", "admin123"); await signIn(page, "admin@nexus.dev", "admin123");
await page.goto("/allocations"); await page.goto("/allocations");
}); });
test("seeded assignments stay visibly rendered on first load", async ({ page }) => { test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
page.locator("h1").filter({ hasText: /Allocations|Planning/i }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0); await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0); expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
}); });
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => { test("explicitly restrictive filters show a visible empty state and can be reset", async ({
page,
}) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const projectFilter = page.getByPlaceholder("Filter by project…"); const projectFilter = page.getByPlaceholder("Filter by project…");
@@ -83,21 +87,23 @@ test.describe("Allocations", () => {
await expect(newBtn).toBeVisible({ timeout: 10000 }); await expect(newBtn).toBeVisible({ timeout: 10000 });
await newBtn.click(); await newBtn.click();
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 }); await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
await expect(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"); await page.keyboard.press("Escape");
}); });
test("filter by status works", async ({ page }) => { test("filter by status works", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for status filter chips or dropdown // Look for status filter chips or dropdown
const statusFilter = page.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) { if ((await statusFilter.count()) > 0) {
await statusFilter.click(); await statusFilter.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
// After clicking a status filter, the page should still show the table // After clicking a status filter, the page should still show the table
await expect( await expect(page.locator("table").or(page.locator("text=No allocations"))).toBeVisible();
page.locator("table").or(page.locator("text=No allocations")),
).toBeVisible();
} }
}); });
@@ -108,17 +114,17 @@ test.describe("Allocations", () => {
await colToggle.click(); await colToggle.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
// A panel or dropdown with column checkboxes should appear // A panel or dropdown with column checkboxes should appear
await expect( await expect(page.locator("input[type='checkbox']").first()).toBeVisible({ timeout: 3000 });
page.locator("input[type='checkbox']").first(),
).toBeVisible({ timeout: 3000 });
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
} }
}); });
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => { test("viewer sees a visible access error instead of an empty allocations page", async ({
browser,
}) => {
const page = await browser.newPage(); const page = await browser.newPage();
await freezeBrowserTime(page); await freezeBrowserTime(page);
await signIn(page, "viewer@capakraken.dev", "viewer123"); await signIn(page, "viewer@nexus.dev", "viewer123");
await page.goto("/allocations"); await page.goto("/allocations");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
+4 -4
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -71,8 +71,8 @@ test.describe("Analytics / Insights", () => {
test("insights page loads without errors", async ({ page }) => { test("insights page loads without errors", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Page should render some heading or content area — not a hard 404 // Page should render some heading or content area — not a hard 404
await expect( await expect(page.locator("h1").or(page.locator("main")).first()).toBeVisible({
page.locator("h1").or(page.locator("main")).first(), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
}); });
+16 -6
View File
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
const ADMIN_EMAIL = "admin@capakraken.dev"; const ADMIN_EMAIL = "admin@nexus.dev";
const ADMIN_PASSWORD = "admin123"; const ADMIN_PASSWORD = "admin123";
const CURRENT_CONVERSATION_ID = "assistant-e2e-current"; const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db"); const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript((conversationId) => { await page.addInitScript((conversationId) => {
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId); window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
}, CURRENT_CONVERSATION_ID); }, CURRENT_CONVERSATION_ID);
runDb(` 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 suffix = Date.now();
const currentClientName = `E2E Approval Client Current ${suffix}`; const currentClientName = `E2E Approval Client Current ${suffix}`;
const otherClientName = `E2E Approval Client Other ${suffix}`; const otherClientName = `E2E Approval Client Other ${suffix}`;
@@ -210,14 +212,22 @@ test.describe("Assistant approvals", () => {
await expect(page.getByText(currentApproval.summary)).toBeVisible(); await expect(page.getByText(currentApproval.summary)).toBeVisible();
await expect(page.getByText(otherApproval.summary)).toBeVisible(); await expect(page.getByText(otherApproval.summary)).toBeVisible();
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first(); const currentCard = page
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first(); .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(currentCard).toContainText("This chat");
await expect(otherCard).toContainText("Other chat"); await expect(otherCard).toContainText("Other chat");
await otherCard.getByTestId("assistant-approval-cancel").click(); await otherCard.getByTestId("assistant-approval-cancel").click();
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible(); await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
await expect(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 await expect
.poll(async () => { .poll(async () => {
+1 -1
View File
@@ -8,7 +8,7 @@ test.describe("Authentication", () => {
test("admin can sign in", async ({ page }) => { test("admin can sign in", async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
+4 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,9 +16,7 @@ test.describe("Bench Board", () => {
test("bench board page loads with heading", async ({ page }) => { test("bench board page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1", { hasText: "Bench Board" })).toBeVisible({ timeout: 10000 });
page.locator("h1", { hasText: "Bench Board" }),
).toBeVisible({ timeout: 10000 });
}); });
test("date range filter inputs are visible", async ({ page }) => { test("date range filter inputs are visible", async ({ page }) => {
@@ -32,7 +30,8 @@ test.describe("Bench Board", () => {
test("shows bench results or no-resources empty state", async ({ page }) => { test("shows bench results or no-resources empty state", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(
page.locator("table") page
.locator("table")
.or(page.locator("text=No resources on bench")) .or(page.locator("text=No resources on bench"))
.or(page.locator("text=No results")) .or(page.locator("text=No results"))
.first(), .first(),
+4 -2
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Dashboard", () => { test.describe("Dashboard", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -31,7 +31,9 @@ test.describe("Dashboard", () => {
const addBtn = page.locator("button", { hasText: /Add Widget/i }); const addBtn = page.locator("button", { hasText: /Add Widget/i });
if ((await addBtn.count()) > 0) { if ((await addBtn.count()) > 0) {
await addBtn.click(); await addBtn.click();
await expect(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"); 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 page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 }); await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
// Error message visible // Error message visible
await expect( await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
page.locator("text=/invalid|incorrect|wrong|credentials/i"), timeout: 5000,
).toBeVisible({ timeout: 5000 }); });
}); });
test("after logout, protected routes redirect to sign-in", async ({ page }) => { 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 // At least one user row should be visible
await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); 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, timeout: 10000,
}); });
await expect(page.locator("text=No users found")).toHaveCount(0); 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. * - Creates a temporary test user via tRPC (admin session) for isolation.
* - Cleans up the test user in afterAll. * - Cleans up the test user in afterAll.
* - Uses an empty storageState to ensure no cross-user localStorage bleed. * - 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"; import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
// ─── tRPC helpers ───────────────────────────────────────────────────────────── // ─── 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( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, { const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -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( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } })); const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
// Default layout should show at least the stat-cards widget // Default layout should show at least the stat-cards widget
// (from createDefaultDashboardLayout in useDashboardLayout) // (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, timeout: 8000,
}); });
}); });
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page); await navigateToDashboard(page);
// Open modal // 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 // 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 // Verify widget entries are visible in the modal
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal // The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
const widgetButtons = page.locator( const widgetButtons = page
'[role="dialog"] button, .fixed button[type="button"]', .locator('[role="dialog"] button, .fixed button[type="button"]')
).filter({ hasText: /./ }); .filter({ hasText: /./ });
// Count items in the grid (the ×-close button is excluded by checking for icon content) // Count items in the grid (the ×-close button is excluded by checking for icon content)
const modalContent = page.locator(".fixed.inset-0 .grid"); 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(); const initialCount = await page.locator(".react-grid-item").count();
// Open modal and add "Resource Table" widget // 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 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 // Modal should close after adding
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 }); await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
await navigateToDashboard(page); await navigateToDashboard(page);
// Add a recognizable widget // 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 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 }); await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
const countAfterAdd = await page.locator(".react-grid-item").count(); 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 // Read the admin's localStorage key to verify it is user-scoped
const adminUserId = await adminPage.evaluate(async () => { const adminUserId = await adminPage.evaluate(async () => {
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), { const res = await fetch(
credentials: "include", "/api/trpc/user.me?batch=1&input=" +
}); encodeURIComponent(JSON.stringify({ "0": { json: null } })),
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }]; {
credentials: "include",
},
);
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
return body[0]?.result?.data?.json?.id ?? null; 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) { if (adminUserId) {
const storageKey = await adminPage.evaluate((userId) => { const storageKey = await adminPage.evaluate((userId) => {
// Check both old (unscoped) and new (user-scoped) key formats // Check both old (unscoped) and new (user-scoped) key formats
const oldKey = "capakraken_dashboard_v1"; const oldKey = "nexus_dashboard_v1";
const newKey = `capakraken_dashboard_v1_${userId}`; const newKey = `nexus_dashboard_v1_${userId}`;
const oldValue = localStorage.getItem(oldKey); const oldValue = localStorage.getItem(oldKey);
const newValue = localStorage.getItem(newKey); const newValue = localStorage.getItem(newKey);
return { oldKey: oldValue !== null, newKey: newValue !== null }; 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 // Inject the admin's storage key to simulate same browser
await newUserPage.evaluate( await newUserPage.evaluate(
({ key, value }) => { localStorage.setItem(key, value ?? ""); }, ({ key, value }) => {
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) }, localStorage.setItem(key, value ?? "");
},
{
key: `nexus_dashboard_v1_${adminUserId}`,
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
},
); );
// Log in as test user // Log in as test user
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
const gridItems = await newUserPage.locator(".react-grid-item").count(); 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 // 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 // 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 // Modal must show widgets to choose from
const modalContent = newUserPage.locator(".fixed.inset-0 .grid"); const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
+3 -3
View File
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
password: "Dev123456!", password: "Dev123456!",
}; };
const DB_CONTAINER = "capakraken-postgres-1"; const DB_CONTAINER = "nexus-postgres-1";
const DB_USER = "capakraken"; const DB_USER = "nexus";
const DB_NAME = "capakraken"; const DB_NAME = "nexus";
function psqlExec(sql: string): string { function psqlExec(sql: string): string {
return execSync( 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.goto("/dashboard"); // land on any authenticated page for cookie context
await page.evaluate(async () => { await page.evaluate(async () => {
const csrfRes = await fetch("/api/auth/csrf"); 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", { await fetch("/api/auth/signout", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, 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(); const enc = (encoding ?? "").toLowerCase().trim();
if (enc === "quoted-printable") { if (enc === "quoted-printable") {
return body return body
.replace(/=\r\n/g, "") // soft line break (CRLF) .replace(/=\r\n/g, "") // soft line break (CRLF)
.replace(/=\n/g, "") // soft line break (LF) .replace(/=\n/g, "") // soft line break (LF)
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => .replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
String.fromCharCode(parseInt(hex, 16)),
);
} }
if (enc === "base64") { if (enc === "base64") {
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8"); return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
*/ */
export async function getLatestEmailTo( export async function getLatestEmailTo(
address: string, 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 }> { ): Promise<{ subject: string; body: string; html: string }> {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
pathPrefix: string, pathPrefix: string,
): string { ): string {
const text = email.html || email.body; 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]) { if (!match?.[0]) {
throw new Error(`No URL with prefix "${pathPrefix}" found in email`); 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 // 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 // Column name is camelCase (Prisma default) — must be double-quoted in SQL
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`; const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
execSync( execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`, input: sql,
{ input: sql, encoding: "utf8" }, encoding: "utf8",
); });
} }
// ── tRPC helpers ─────────────────────────────────────────────────────────────── // ── 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 }) => { 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 // Step 1: Navigate to admin users page
await page.goto("/admin/users"); await page.goto("/admin/users");
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
// Step 2: Open invite modal // Step 2: Open invite modal
await page.click('button:has-text("Invite User")'); await page.click('button:has-text("Invite User")');
// Wait for the modal heading — AnimatedModal does not use role="dialog" // 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 // Step 3: Fill in invite form
await page.fill('input[type="email"]', testEmail); await page.fill('input[type="email"]', testEmail);
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
await page.click('button:has-text("Send Invite")'); await page.click('button:has-text("Send Invite")');
// Step 5: Wait for success message (exact text from InviteUserModal.tsx) // 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 // Step 6: Read invite email from Mailhog
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 }); 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 ───────────────────────────────────────────────────────────── // ─── 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( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const res = await fetch(`/api/trpc/${procedure}?batch=1`, { const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
@@ -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( return page.evaluate(
async ({ procedure, input }) => { async ({ procedure, input }) => {
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } })); const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
@@ -60,7 +71,7 @@ async function enableMfaForSession(page: Page): Promise<TOTP> {
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`); if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
const totp = new TOTP({ const totp = new TOTP({
issuer: "CapaKraken", issuer: "Nexus",
algorithm: "SHA1", algorithm: "SHA1",
digits: 6, digits: 6,
period: 30, period: 30,
@@ -92,7 +103,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
test.afterEach(async ({ page }) => { test.afterEach(async ({ page }) => {
// Clean up: disable MFA if a test enabled it // Clean up: disable MFA if a test enabled it
if (totp) { if (totp) {
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */}); await disableMfaForSession(page).catch(() => {
/* already disabled or admin override */
});
totp = null; totp = null;
} }
}); });
@@ -106,7 +119,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
expect(data?.secret).toBeTruthy(); expect(data?.secret).toBeTruthy();
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//); expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
expect(data?.uri).toContain("CapaKraken"); expect(data?.uri).toContain("Nexus");
}); });
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => { 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"); await page.waitForLoadState("networkidle");
// Click the enable/setup button if MFA is not yet enabled // Click the enable/setup button if MFA is not yet enabled
const setupBtn = page.getByRole("button", { name: /set up/i }).or( const setupBtn = page
page.getByRole("button", { name: /enable.*mfa/i }), .getByRole("button", { name: /set up/i })
); .or(page.getByRole("button", { name: /enable.*mfa/i }));
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) { if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await setupBtn.click(); await setupBtn.click();
@@ -233,9 +246,10 @@ test.describe("MFA — login flow", () => {
// Should show error and remain on TOTP step // Should show error and remain on TOTP step
await expect( await expect(
page.getByText(/invalid.*code|incorrect.*token|try again/i).or( page
page.locator("[data-error]"), .getByText(/invalid.*code|incorrect.*token|try again/i)
).first(), .or(page.locator("[data-error]"))
.first(),
).toBeVisible({ timeout: 5000 }); ).toBeVisible({ timeout: 5000 });
// Should NOT have navigated away // Should NOT have navigated away
@@ -248,7 +262,9 @@ test.describe("MFA — login flow", () => {
test.describe("MFA — users without MFA enabled", () => { test.describe("MFA — users without MFA enabled", () => {
test.use({ storageState: { cookies: [], origins: [] } }); test.use({ storageState: { cookies: [], origins: [] } });
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => { test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
page,
}) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@planarchy.dev"); await page.fill('input[type="email"]', "manager@planarchy.dev");
await page.fill('input[type="password"]', "manager123"); await page.fill('input[type="password"]', "manager123");
+1 -1
View File
@@ -8,7 +8,7 @@
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts) * Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
* *
* Run: * Run:
* pnpm --filter @capakraken/web exec playwright test \ * pnpm --filter @nexus/web exec playwright test \
* --config playwright.dev.config.ts \ * --config playwright.dev.config.ts \
* e2e/dev-system/nav-smoke.spec.ts * e2e/dev-system/nav-smoke.spec.ts
*/ */
@@ -27,10 +27,10 @@ test.describe("RBAC — admin routes (admin session)", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
// Seed users have planarchy.dev or capakraken.dev email domains // Seed users have planarchy.dev or nexus.dev email domains
await expect( await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("admin can access /admin/system-roles without errors", async ({ page }) => { test("admin can access /admin/system-roles without errors", async ({ page }) => {
@@ -99,9 +99,10 @@ test.describe("RBAC — allocations permitted for admin", () => {
await page.goto("/allocations"); await page.goto("/allocations");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
page.locator("text=/do not have permission to view allocations/i"), 0,
).toHaveCount(0, { timeout: 8000 }); { timeout: 8000 },
);
}); });
}); });
@@ -112,9 +113,10 @@ test.describe("RBAC — allocations permitted for manager", () => {
await page.goto("/allocations"); await page.goto("/allocations");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
page.locator("text=/do not have permission to view allocations/i"), 0,
).toHaveCount(0, { timeout: 8000 }); { timeout: 8000 },
);
}); });
}); });
+26 -17
View File
@@ -10,22 +10,26 @@ async function signIn(page: Page, email: string, password: string) {
test.describe("Estimates", () => { test.describe("Estimates", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await signIn(page, "admin@capakraken.dev", "admin123"); await signIn(page, "admin@nexus.dev", "admin123");
await page.goto("/estimates"); await page.goto("/estimates");
}); });
test("estimate list loads", async ({ page }) => { test("estimate list loads", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.getByRole("heading", { name: /estimate workspace/i })).toBeVisible({
timeout: 10000,
});
await expect(page.getByPlaceholder("Search by estimate or opportunity")).toBeVisible({
timeout: 10000,
});
await expect( await expect(
page.getByRole("heading", { name: /estimate workspace/i }), page
).toBeVisible({ timeout: 10000 }); .locator("text=No estimates yet")
await expect( .or(
page.getByPlaceholder("Search by estimate or opportunity"), page.locator(
).toBeVisible({ timeout: 10000 }); "text=Select an estimate to inspect the current version, demand lines, and summary metrics.",
await expect( ),
page.locator("text=No estimates yet").or( ),
page.locator("text=Select an estimate to inspect the current version, demand lines, and summary metrics."),
),
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
}); });
@@ -44,8 +48,13 @@ test.describe("Estimates", () => {
await page.locator("button", { hasText: /New Estimate/i }).click(); await page.locator("button", { hasText: /New Estimate/i }).click();
// Step 1: Setup — fill a name // Step 1: Setup — fill a name
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({ timeout: 5000 }); await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({
const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first(); timeout: 5000,
});
const nameInput = page
.locator('input[placeholder*="name"]')
.or(page.locator('input[name="name"]'))
.first();
if ((await nameInput.count()) > 0) { if ((await nameInput.count()) > 0) {
await nameInput.fill(`E2E Estimate ${Date.now()}`); await nameInput.fill(`E2E Estimate ${Date.now()}`);
} }
@@ -90,9 +99,7 @@ test.describe("Estimates", () => {
test("shows the empty-state fallback when no estimates exist", async ({ page }) => { test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("text=No estimates yet")).toBeVisible({ timeout: 10000 });
page.locator("text=No estimates yet"),
).toBeVisible({ timeout: 10000 });
}); });
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => { test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
@@ -103,12 +110,14 @@ test.describe("Estimates", () => {
test("shows the restricted workspace fallback for viewers", async ({ browser }) => { test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
const page = await browser.newPage(); const page = await browser.newPage();
await signIn(page, "viewer@capakraken.dev", "viewer123"); await signIn(page, "viewer@nexus.dev", "viewer123");
await page.goto("/estimates/missing-estimate"); await page.goto("/estimates/missing-estimate");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(
page.locator("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 }); ).toBeVisible({ timeout: 10000 });
await page.close(); await page.close();
+20 -6
View File
@@ -2,14 +2,16 @@ import { expect, test, type Page } from "@playwright/test";
async function signInAsAdmin(page: Page) { async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
} }
test.describe("Holiday Calendar Editor", () => { test.describe("Holiday Calendar Editor", () => {
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => { test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({
page,
}) => {
const suffix = Date.now().toString(); const suffix = Date.now().toString();
const calendarName = `E2E City Calendar ${suffix}`; const calendarName = `E2E City Calendar ${suffix}`;
const holidayName = `E2E Local Holiday ${suffix}`; const holidayName = `E2E Local Holiday ${suffix}`;
@@ -21,11 +23,18 @@ test.describe("Holiday Calendar Editor", () => {
await page.getByTestId("holiday-calendar-name-input").fill(calendarName); await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY"); await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
await page.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-city-select").selectOption({ label: "Muenchen" });
await page.getByTestId("holiday-calendar-create-button").click(); 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.getByRole("heading", { name: calendarName })).toBeVisible();
await expect(page.getByTestId("holiday-entry-create-button")).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-name-input").fill(`${holidayName} Duplicate`);
await page.getByTestId("holiday-entry-create-button").click(); 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()); 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(); await expect(page.getByText(holidayName).first()).not.toBeVisible();
page.once("dialog", (dialog) => dialog.accept()); page.once("dialog", (dialog) => dialog.accept());
+10 -4
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Navigation", () => { test.describe("Navigation", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -28,7 +28,7 @@ test.describe("Navigation", () => {
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => { test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
// Complements the click-based test above with a direct-navigation check // Complements the click-based test above with a direct-navigation check
// covering every sidebar destination. Uses admin@capakraken.dev (ADMIN role). // covering every sidebar destination. Uses admin@nexus.dev (ADMIN role).
const routes = [ const routes = [
// Already covered by click test but included for completeness // Already covered by click test but included for completeness
"/dashboard", "/dashboard",
@@ -79,7 +79,10 @@ test.describe("Navigation", () => {
} }
// Expand again — the button should still be visible as an icon // Expand again — the button should still be visible as an icon
const expandBtn = page.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 expandBtn.click();
await page.waitForTimeout(300); await page.waitForTimeout(300);
const boxExpanded = await nav.boundingBox(); const boxExpanded = await nav.boundingBox();
@@ -113,7 +116,10 @@ test.describe("Navigation", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// The hamburger button should be visible on mobile // The hamburger button should be visible on mobile
const hamburgerBtn = page.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 expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
await hamburgerBtn.click(); await hamburgerBtn.click();
+9 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -74,9 +74,9 @@ test.describe("Project Detail Page", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// BudgetStatusCard renders budget-related content // BudgetStatusCard renders budget-related content
await expect( await expect(page.locator("text=Budget").or(page.locator("text=budget")).first()).toBeVisible({
page.locator("text=Budget").or(page.locator("text=budget")).first(), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("unknown project id shows not-found state", async ({ page }) => { 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 // Server-side notFound() triggers the Next.js 404 page
await expect( 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 }); ).toBeVisible({ timeout: 10000 });
}); });
+24 -10
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Projects", () => { test.describe("Projects", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@capakraken.dev"); await page.fill('input[type="email"]', "manager@nexus.dev");
await page.fill('input[type="password"]', "manager123"); await page.fill('input[type="password"]', "manager123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/resources/); await expect(page).toHaveURL(/\/resources/);
@@ -26,9 +26,16 @@ test.describe("Projects", () => {
// Step 1: Blueprint selection // Step 1: Blueprint selection
await expect(page.locator("text=Select Blueprint")).toBeVisible(); await expect(page.locator("text=Select Blueprint")).toBeVisible();
// Select the first available blueprint // Select the first available blueprint
const blueprintCard = page.locator("[data-blueprint-id]").first() const blueprintCard = page
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first()); .locator("[data-blueprint-id]")
if (await blueprintCard.count() > 0) { .first()
.or(
page
.locator("button")
.filter({ hasText: /Blueprint|Production/ })
.first(),
);
if ((await blueprintCard.count()) > 0) {
await blueprintCard.click(); await blueprintCard.click();
} else { } else {
// Click next without blueprint if none shown // Click next without blueprint if none shown
@@ -37,16 +44,21 @@ test.describe("Projects", () => {
} }
// Step 2: Timeline — set project dates // Step 2: Timeline — set project dates
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 }); await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first(); timeout: 5000,
if (await projectNameInput.count() > 0) { });
const projectNameInput = page
.locator('input[placeholder*="name"]')
.or(page.locator('input[name="name"]'))
.first();
if ((await projectNameInput.count()) > 0) {
await projectNameInput.fill(`E2E Test Project ${Date.now()}`); await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
} }
await page.locator("button", { hasText: "Next" }).click(); await page.locator("button", { hasText: "Next" }).click();
// Step 3: Staffing demand // Step 3: Staffing demand
await expect( await expect(
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles"))) page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles"))),
).toBeVisible({ timeout: 5000 }); ).toBeVisible({ timeout: 5000 });
await page.locator("button", { hasText: "Next" }).click(); await page.locator("button", { hasText: "Next" }).click();
@@ -56,11 +68,13 @@ test.describe("Projects", () => {
// Step 5: Review // Step 5: Review
await page.waitForTimeout(500); 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 }); await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
// Don't actually submit — just close // Don't actually submit — just close
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first(); const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
if (await cancelBtn.count() > 0) { if ((await cancelBtn.count()) > 0) {
await cancelBtn.click(); await cancelBtn.click();
} }
}); });
+17 -12
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,16 +16,17 @@ test.describe("Chargeability Report", () => {
test("chargeability forecast page loads with heading", async ({ page }) => { test("chargeability forecast page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1", { hasText: "Chargeability Forecast" })).toBeVisible({
page.locator("h1", { hasText: "Chargeability Forecast" }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("filter controls are present", async ({ page }) => { test("filter controls are present", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Should have at least one filter (e.g., chapter, period, resource search) // Should have at least one filter (e.g., chapter, period, resource search)
await expect( await expect(
page.locator('input[type="text"]') page
.locator('input[type="text"]')
.or(page.locator('input[type="search"]')) .or(page.locator('input[type="search"]'))
.or(page.locator("select")) .or(page.locator("select"))
.first(), .first(),
@@ -64,9 +65,9 @@ test.describe("Report Builder", () => {
test("report builder page loads with heading", async ({ page }) => { test("report builder page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.getByRole("heading", { name: "Report Builder" })).toBeVisible({
page.getByRole("heading", { name: "Report Builder" }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("entity selector is present with expected options", async ({ page }) => { 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 }) => { test("run report button is visible", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("button", { hasText: /Run|Export|Generate/i }).first()).toBeVisible({
page.locator("button", { hasText: /Run|Export|Generate/i }).first(), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("running a default report produces output or empty state", async ({ page }) => { test("running a default report produces output or empty state", async ({ page }) => {
@@ -90,7 +91,11 @@ test.describe("Report Builder", () => {
await runBtn.click(); await runBtn.click();
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
await expect( 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 }); ).toBeVisible({ timeout: 15000 });
} }
}); });
+6 -5
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Resources", () => { test.describe("Resources", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "manager@capakraken.dev"); await page.fill('input[type="email"]', "manager@nexus.dev");
await page.fill('input[type="password"]', "manager123"); await page.fill('input[type="password"]', "manager123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -21,10 +21,11 @@ test.describe("Resources", () => {
await expect(rows.first()).toBeVisible(); await expect(rows.first()).toBeVisible();
const firstRowText = (await rows.first().textContent()) ?? ""; const firstRowText = (await rows.first().textContent()) ?? "";
const searchTerm = firstRowText const searchTerm =
.split(/\s+/) firstRowText
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim()) .split(/\s+/)
.find((token) => token.length >= 3) ?? "EMP"; .map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
.find((token) => token.length >= 3) ?? "EMP";
const searchInput = page.locator('input[type="search"]'); const searchInput = page.locator('input[type="search"]');
await searchInput.fill(searchTerm); await searchInput.fill(searchTerm);
+6 -5
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signIn(page: Page) { async function signIn(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -16,15 +16,16 @@ test.describe("Scenario Planning", () => {
test("scenarios page loads with heading", async ({ page }) => { test("scenarios page loads with heading", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("h1", { hasText: /Scenario Planning/i })).toBeVisible({
page.locator("h1", { hasText: /Scenario Planning/i }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("shows scenarios list or empty state", async ({ page }) => { test("shows scenarios list or empty state", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(
page.locator("table") page
.locator("table")
.or(page.locator("text=No scenarios")) .or(page.locator("text=No scenarios"))
.or(page.locator("text=Create a project first")) .or(page.locator("text=Create a project first"))
.or(page.locator("[data-testid]")) .or(page.locator("[data-testid]"))
+2 -2
View File
@@ -29,7 +29,7 @@ test("signin page renders credential inputs and submit button", async ({ page })
test("admin login succeeds and redirects away from signin", async ({ page }) => { test("admin login succeeds and redirects away from signin", async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 }); await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
@@ -37,7 +37,7 @@ test("admin login succeeds and redirects away from signin", async ({ page }) =>
test("authenticated user sees app shell nav", async ({ page }) => { test("authenticated user sees app shell nav", async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 }); await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
+10 -6
View File
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test.describe("Staffing", () => { test.describe("Staffing", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -12,7 +12,9 @@ test.describe("Staffing", () => {
test("staffing page loads with search form", async ({ page }) => { test("staffing page loads with search form", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ 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 // Search form should have skill input, date fields, and a search button
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 }); await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
}); });
@@ -20,9 +22,9 @@ test.describe("Staffing", () => {
test("search form has default skill tags", async ({ page }) => { test("search form has default skill tags", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// The StaffingPanel pre-populates with TypeScript and React skill tags // The StaffingPanel pre-populates with TypeScript and React skill tags
await expect( await expect(page.locator("text=TypeScript").or(page.locator("text=React"))).toBeVisible({
page.locator("text=TypeScript").or(page.locator("text=React")), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("submitting search returns suggestions or empty state", async ({ page }) => { test("submitting search returns suggestions or empty state", async ({ page }) => {
@@ -34,7 +36,9 @@ test.describe("Staffing", () => {
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
// After search, should show either suggestion cards or a "no suggestions" message // After search, should show either suggestion cards or a "no suggestions" message
await expect( await expect(
page.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("[data-suggestion]").first())
.or(page.locator("table").first()), .or(page.locator("table").first()),
).toBeVisible({ timeout: 15000 }); ).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 managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110"; const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`; 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 manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
const composeProjectName = `capakraken-e2e-${process.pid}`; const composeProjectName = `nexus-e2e-${process.pid}`;
const managedEnvKeys = [ const managedEnvKeys = [
"DATABASE_URL", "DATABASE_URL",
"REDIS_URL", "REDIS_URL",
@@ -29,7 +29,7 @@ const managedEnvKeys = [
"NODE_ENV", "NODE_ENV",
"PORT", "PORT",
]; ];
const e2eComposePrefix = "capakraken-e2e-"; const e2eComposePrefix = "nexus-e2e-";
function dockerComposeArgs(...args) { function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args]; return ["compose", "-p", composeProjectName, ...args];
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
try { try {
await runQuiet( await runQuiet(
"docker", "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, workspaceRoot,
); );
return; return;
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
if (selectedTestDbPort !== undefined) { if (selectedTestDbPort !== undefined) {
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort); 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.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName; process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development"; process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
@@ -393,9 +393,9 @@ try {
await cleanupStaleE2eArtifacts(); await cleanupStaleE2eArtifacts();
await ensureE2eDatabaseContainer(); await ensureE2eDatabaseContainer();
} }
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot); await run("pnpm", ["--filter", "@nexus/db", "db:push"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot); await run("pnpm", ["--filter", "@nexus/db", "db:seed"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot); await run("pnpm", ["--filter", "@nexus/db", "db:seed:holidays"], workspaceRoot);
rmSync(webDistDirPath, { recursive: true, force: true }); rmSync(webDistDirPath, { recursive: true, force: true });
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], { const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
+289 -162
View File
@@ -133,7 +133,7 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
data: { data: {
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)}, eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)}, displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)}, email: ${JSON.stringify(`e2e.timeline.${suffix}@nexus.dev`)},
chapter: "E2E", chapter: "E2E",
lcrCents: 5000, lcrCents: 5000,
ucrCents: 9000, ucrCents: 9000,
@@ -208,7 +208,7 @@ function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
data: { data: {
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)}, eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)}, displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)}, email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@nexus.dev`)},
chapter: "E2E", chapter: "E2E",
lcrCents: 5000, lcrCents: 5000,
ucrCents: 9000, ucrCents: 9000,
@@ -341,7 +341,9 @@ function listScenarioAssignments(projectId: string) {
} }
function listScenarioDemands(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({ const demands = await prisma.demandRequirement.findMany({
where: { projectId: ${JSON.stringify(projectId)} }, where: { projectId: ${JSON.stringify(projectId)} },
orderBy: [{ startDate: "asc" }, { endDate: "asc" }], orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
@@ -448,10 +450,7 @@ async function openAllocationContextMenuAtOffset(
); );
} }
async function openContextMenuAtCenter( async function openContextMenuAtCenter(page: Page, locator: ReturnType<Page["locator"]>) {
page: Page,
locator: ReturnType<Page["locator"]>,
) {
const target = await resolveAllocationContextMenuTarget(locator); const target = await resolveAllocationContextMenuTarget(locator);
const box = await readBoundingBox(target); const box = await readBoundingBox(target);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" }); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
@@ -511,9 +510,7 @@ async function listRenderedAllocationSegments(
row: ReturnType<Page["locator"]>, row: ReturnType<Page["locator"]>,
allocationId?: string, allocationId?: string,
) { ) {
const selector = allocationId const selector = allocationId ? `[data-allocation-id="${allocationId}"]` : "[data-allocation-id]";
? `[data-allocation-id="${allocationId}"]`
: "[data-allocation-id]";
return row.locator(selector).evaluateAll((elements) => return row.locator(selector).evaluateAll((elements) =>
elements.map((element) => { elements.map((element) => {
const htmlElement = element as HTMLElement; const htmlElement = element as HTMLElement;
@@ -536,17 +533,13 @@ function escapeRegex(value: string) {
async function signInAsAdmin(page: Page) { async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
} }
async function findVisibleTimelineEntryId( async function findVisibleTimelineEntryId(page: Page, selector: string, minimumWidth = 24) {
page: Page,
selector: string,
minimumWidth = 24,
) {
return page.locator(selector).evaluateAll((elements, minimum) => { return page.locator(selector).evaluateAll((elements, minimum) => {
for (const element of elements) { for (const element of elements) {
if (!(element instanceof HTMLElement)) continue; if (!(element instanceof HTMLElement)) continue;
@@ -600,9 +593,9 @@ async function findVisibleAllocationSegmentForResize(
); );
const stickyHeaderBottom = scrollContainer const stickyHeaderBottom = scrollContainer
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce( ? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom), (maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
0, 0,
) )
: 0; : 0;
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48; const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
const candidates: Array<{ const candidates: Array<{
@@ -611,8 +604,11 @@ async function findVisibleAllocationSegmentForResize(
segmentEnd: string | null; segmentEnd: string | null;
score: number; score: number;
}> = []; }> = [];
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null = let fallback: {
null; allocationId: string;
segmentStart: string | null;
segmentEnd: string | null;
} | null = null;
for (const element of elements) { for (const element of elements) {
if (!(element instanceof HTMLElement)) continue; if (!(element instanceof HTMLElement)) continue;
@@ -829,13 +825,20 @@ async function switchToProjectView(page: Page, readySelector?: string) {
await expect(page.locator(readySelector).first()).toBeVisible(); await expect(page.locator(readySelector).first()).toBeVisible();
} else { } else {
await expect await expect
.poll(async () => { .poll(
const projectRows = await page.getByTestId("timeline-project-resource-row-canvas").count(); async () => {
const projectBars = await page.locator("[data-timeline-entry-type='project-bar']").count(); const projectRows = await page
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count(); .getByTestId("timeline-project-resource-row-canvas")
const emptyStates = await page.getByText(/No projects in this time range/).count(); .count();
return projectRows + projectBars + demandBars + emptyStates; const projectBars = await page
}, { timeout: 10_000 }) .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); .not.toBe(0);
} }
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(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) { async function ensureOpenDemandVisibilityEnabled(page: Page) {
await page.evaluate(() => { 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>) : {}; const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
window.localStorage.setItem( window.localStorage.setItem(
"capakraken_prefs", "nexus_prefs",
JSON.stringify({ JSON.stringify({
...parsed, ...parsed,
showDemandProjects: true, showDemandProjects: true,
@@ -871,9 +874,9 @@ test.describe("Timeline", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" })); localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
localStorage.setItem( localStorage.setItem(
"capakraken_prefs", "nexus_prefs",
JSON.stringify({ JSON.stringify({
hideCompletedProjects: true, hideCompletedProjects: true,
timelineDisplayMode: "strip", timelineDisplayMode: "strip",
@@ -906,22 +909,21 @@ test.describe("Timeline", () => {
await expect(page.locator("text=/\\d+ resources/")).toBeVisible(); await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
}); });
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ 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 suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const scenario = createTimelineSegmentScenario(suffix); const scenario = createTimelineSegmentScenario(suffix);
try { try {
await page.goto( await page.goto(`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, {
`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, waitUntil: "domcontentloaded",
{ waitUntil: "domcontentloaded" }, });
);
const projectButton = page.getByRole("button", { name: "Project view" }); const projectButton = page.getByRole("button", { name: "Project view" });
const resourceButton = page.getByRole("button", { name: "Resource view" }); const resourceButton = page.getByRole("button", { name: "Resource view" });
const resourceRowSelector = const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
`[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 projectRowSelector =
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
await expect(projectButton).toBeDisabled(); await expect(projectButton).toBeDisabled();
await expect(resourceButton).toBeDisabled(); await expect(resourceButton).toBeDisabled();
@@ -951,9 +953,9 @@ test.describe("Timeline", () => {
test("keeps timeline data populated after navigating from allocations", async ({ page }) => { test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
await page.goto("/allocations"); await page.goto("/allocations");
await expect( await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
page.locator("h1").filter({ hasText: /Allocations|Planning/i }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
await page.locator('nav a >> text="Timeline"').first().click(); await page.locator('nav a >> text="Timeline"').first().click();
await expect(page).toHaveURL(/\/timeline/); await expect(page).toHaveURL(/\/timeline/);
@@ -1046,7 +1048,10 @@ test.describe("Timeline", () => {
if (!projectAllocationBox) { if (!projectAllocationBox) {
throw new Error("Expected a project allocation block to be available"); throw new Error("Expected a project allocation block to be available");
} }
await page.mouse.move(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(heatmapTooltip).toBeVisible();
await expect await expect
.poll(async () => { .poll(async () => {
@@ -1071,7 +1076,9 @@ test.describe("Timeline", () => {
.first(); .first();
await allocation.click({ button: "right" }); await allocation.click({ button: "right" });
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 }); await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
timeout: 2_000,
});
const popover = page.getByTestId("timeline-allocation-popover"); const popover = page.getByTestId("timeline-allocation-popover");
await expect(popover).toBeVisible(); await expect(popover).toBeVisible();
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0); await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
@@ -1103,12 +1110,16 @@ test.describe("Timeline", () => {
waitUntil: "domcontentloaded", 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(); await expect(row).toBeVisible();
const holidayBlock = row.locator( const holidayBlock = row
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]', .locator(
).first(); '[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
)
.first();
await expect(holidayBlock).toBeVisible(); await expect(holidayBlock).toBeVisible();
const rowBox = await row.boundingBox(); const rowBox = await row.boundingBox();
@@ -1129,7 +1140,9 @@ test.describe("Timeline", () => {
const holidayTooltip = page const holidayTooltip = page
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50") .locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
.or(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(); .first();
await expect(holidayTooltip).toBeVisible(); await expect(holidayTooltip).toBeVisible();
@@ -1278,9 +1291,7 @@ test.describe("Timeline", () => {
expect(result.maxGap).toBeLessThan(24); expect(result.maxGap).toBeLessThan(24);
}); });
test("allocation resize shows a live preview before mouseup", async ({ test("allocation resize shows a live preview before mouseup", async ({ page }) => {
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", { await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
@@ -1358,9 +1369,7 @@ test.describe("Timeline", () => {
expect(secondResize.rightEdgeGain).toBeGreaterThan(48); expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
}); });
test("allocation start resize shows a live preview before mouseup", async ({ test("allocation start resize shows a live preview before mouseup", async ({ page }) => {
page,
}) => {
await page.goto("/timeline?startDate=2026-04-01&days=31", { await page.goto("/timeline?startDate=2026-04-01&days=31", {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
@@ -1394,18 +1403,17 @@ test.describe("Timeline", () => {
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, { await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
}); });
const resourceRowSelector = const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
`[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 projectRowSelector = const projectAllocationSelector = `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
const projectAllocationSelector =
`${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
await expect(page.locator(resourceRowSelector)).toBeVisible(); await expect(page.locator(resourceRowSelector)).toBeVisible();
await expect( await expect(
page.locator( page
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`, .locator(
).first(), `[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
)
.first(),
).toBeVisible(); ).toBeVisible();
await switchToProjectView(page, projectRowSelector); await switchToProjectView(page, projectRowSelector);
@@ -1427,19 +1435,22 @@ test.describe("Timeline", () => {
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48); expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = []; let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
await expect await expect
.poll(() => { .poll(
rightResizeAssignments = listScenarioAssignments(scenario.projectId); () => {
if (rightResizeAssignments.length !== 1) { rightResizeAssignments = listScenarioAssignments(scenario.projectId);
return null; if (rightResizeAssignments.length !== 1) {
} return null;
}
const [assignment] = rightResizeAssignments; const [assignment] = rightResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) { if (!assignment || assignment.id !== scenario.assignmentId) {
return null; return null;
} }
return assignment.endDate; return assignment.endDate;
}, { timeout: 15_000 }) },
{ timeout: 15_000 },
)
.not.toBe("2026-04-17"); .not.toBe("2026-04-17");
expect(rightResizeAssignments).toHaveLength(1); expect(rightResizeAssignments).toHaveLength(1);
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId); expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
@@ -1451,19 +1462,22 @@ test.describe("Timeline", () => {
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36); expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = []; let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
await expect await expect
.poll(() => { .poll(
leftResizeAssignments = listScenarioAssignments(scenario.projectId); () => {
if (leftResizeAssignments.length !== 1) { leftResizeAssignments = listScenarioAssignments(scenario.projectId);
return null; if (leftResizeAssignments.length !== 1) {
} return null;
}
const [assignment] = leftResizeAssignments; const [assignment] = leftResizeAssignments;
if (!assignment || assignment.id !== scenario.assignmentId) { if (!assignment || assignment.id !== scenario.assignmentId) {
return null; return null;
} }
return assignment.startDate; return assignment.startDate;
}, { timeout: 15_000 }) },
{ timeout: 15_000 },
)
.not.toBe("2026-04-06"); .not.toBe("2026-04-06");
expect(leftResizeAssignments).toHaveLength(1); expect(leftResizeAssignments).toHaveLength(1);
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId); expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
@@ -1479,15 +1493,12 @@ test.describe("Timeline", () => {
const scenario = createTimelineDemandScenario(suffix); const scenario = createTimelineDemandScenario(suffix);
try { try {
await page.goto( await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, waitUntil: "domcontentloaded",
{ waitUntil: "domcontentloaded" }, });
);
await ensureOpenDemandVisibilityEnabled(page); await ensureOpenDemandVisibilityEnabled(page);
const demandRowSelector = const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`; const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
const demandSelector =
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
await switchToProjectView(page, demandRowSelector); await switchToProjectView(page, demandRowSelector);
await expect(page.getByText(scenario.projectName).first()).toBeVisible(); await expect(page.getByText(scenario.projectName).first()).toBeVisible();
@@ -1505,19 +1516,22 @@ test.describe("Timeline", () => {
status: string; status: string;
}> = []; }> = [];
await expect await expect
.poll(() => { .poll(
rightResizeDemands = listScenarioDemands(scenario.projectId); () => {
if (rightResizeDemands.length !== 1) { rightResizeDemands = listScenarioDemands(scenario.projectId);
return null; if (rightResizeDemands.length !== 1) {
} return null;
}
const [demand] = rightResizeDemands; const [demand] = rightResizeDemands;
if (!demand || demand.id !== scenario.demandId) { if (!demand || demand.id !== scenario.demandId) {
return null; return null;
} }
return demand.endDate; return demand.endDate;
}, { timeout: 15_000 }) },
{ timeout: 15_000 },
)
.not.toBe("2026-04-16"); .not.toBe("2026-04-16");
expect(rightResizeDemands).toHaveLength(1); expect(rightResizeDemands).toHaveLength(1);
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId); expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
@@ -1538,19 +1552,22 @@ test.describe("Timeline", () => {
status: string; status: string;
}> = []; }> = [];
await expect await expect
.poll(() => { .poll(
leftResizeDemands = listScenarioDemands(scenario.projectId); () => {
if (leftResizeDemands.length !== 1) { leftResizeDemands = listScenarioDemands(scenario.projectId);
return null; if (leftResizeDemands.length !== 1) {
} return null;
}
const [demand] = leftResizeDemands; const [demand] = leftResizeDemands;
if (!demand || demand.id !== scenario.demandId) { if (!demand || demand.id !== scenario.demandId) {
return null; return null;
} }
return demand.startDate; return demand.startDate;
}, { timeout: 15_000 }) },
{ timeout: 15_000 },
)
.not.toBe("2026-04-07"); .not.toBe("2026-04-07");
expect(leftResizeDemands).toHaveLength(1); expect(leftResizeDemands).toHaveLength(1);
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId); expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
@@ -1630,7 +1647,11 @@ test.describe("Timeline", () => {
); );
await expect(resizedSegment).toBeVisible(); 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 releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [ await waitForScenarioAssignments(scenario.projectId, [
@@ -1674,9 +1695,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-11", endDate: "2026-04-17" }, { 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 leftSplit = row
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); .locator(
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); '[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(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible(); await expect(rightSplit).toBeVisible();
await expect(nextWeekSegment).toBeVisible(); await expect(nextWeekSegment).toBeVisible();
@@ -1704,22 +1737,42 @@ test.describe("Timeline", () => {
]); ]);
await expect( 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(); ).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect( 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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
} finally { } finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -1769,9 +1822,21 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(); const leftSplit = row
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(); .locator(
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); '[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(leftSplit).toBeVisible();
await expect(fridayBridge).toBeVisible(); await expect(fridayBridge).toBeVisible();
await expect(mondaySegment).toBeVisible(); await expect(mondaySegment).toBeVisible();
@@ -1797,13 +1862,25 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect( await expect(
row.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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
} finally { } finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -1850,9 +1927,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" }, { 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 leftSplit = row
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); .locator(
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); '[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(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible(); await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible(); await expect(mondaySegment).toBeVisible();
@@ -1870,8 +1959,16 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" }, { 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(); const resizedRightSplit = row
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth); .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 releaseMouse(page);
await waitForScenarioAssignments(scenario.projectId, [ await waitForScenarioAssignments(scenario.projectId, [
@@ -1883,9 +1980,11 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
const mondaySegmentAfterReload = row.locator( const mondaySegmentAfterReload = row
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]', .locator(
).first(); '[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first();
await expect(mondaySegmentAfterReload).toBeVisible(); await expect(mondaySegmentAfterReload).toBeVisible();
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
@@ -1951,9 +2050,21 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" }, { 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 leftSplit = row
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); .locator(
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); '[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(leftSplit).toBeVisible();
await expect(rightSplit).toBeVisible(); await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible(); await expect(mondaySegment).toBeVisible();
@@ -1968,7 +2079,11 @@ test.describe("Timeline", () => {
]); ]);
await expect( 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); ).toHaveCount(0);
await expect(rightSplit).toBeVisible(); await expect(rightSplit).toBeVisible();
await expect(mondaySegment).toBeVisible(); await expect(mondaySegment).toBeVisible();
@@ -1976,13 +2091,25 @@ test.describe("Timeline", () => {
await page.reload({ waitUntil: "domcontentloaded" }); await page.reload({ waitUntil: "domcontentloaded" });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect( await expect(
row.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); ).toHaveCount(0);
await expect( 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(); ).toBeVisible();
await expect( 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(); ).toBeVisible();
} finally { } finally {
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
@@ -2029,13 +2156,14 @@ test.describe("Timeline", () => {
{ startDate: "2026-04-09", endDate: "2026-04-17" }, { startDate: "2026-04-09", endDate: "2026-04-17" },
]); ]);
const mondaySegment = resourceRow.locator( const mondaySegment = resourceRow
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]', .locator(
).first(); '[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
)
.first();
await expect(mondaySegment).toBeVisible(); await expect(mondaySegment).toBeVisible();
const projectRowSelector = const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
await switchToProjectView(page, projectRowSelector); await switchToProjectView(page, projectRowSelector);
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null; let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
@@ -2072,7 +2200,9 @@ test.describe("Timeline", () => {
await expect(page.getByText(scenario.projectName).first()).toBeVisible(); await expect(page.getByText(scenario.projectName).first()).toBeVisible();
const projectAllocationAfterReload = page const projectAllocationAfterReload = page
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`) .locator(
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
)
.first(); .first();
await expect(projectAllocationAfterReload).toBeVisible(); await expect(projectAllocationAfterReload).toBeVisible();
await openContextMenuAtCenter(page, projectAllocationAfterReload); await openContextMenuAtCenter(page, projectAllocationAfterReload);
@@ -2093,15 +2223,12 @@ test.describe("Timeline", () => {
const scenario = createTimelineDemandScenario(suffix); const scenario = createTimelineDemandScenario(suffix);
try { try {
await page.goto( await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, waitUntil: "domcontentloaded",
{ waitUntil: "domcontentloaded" }, });
);
await ensureOpenDemandVisibilityEnabled(page); await ensureOpenDemandVisibilityEnabled(page);
const demandRowSelector = const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`; const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
const demandSelector =
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
await switchToProjectView(page, demandRowSelector); await switchToProjectView(page, demandRowSelector);
await expect(page.locator(demandSelector)).toBeVisible(); await expect(page.locator(demandSelector)).toBeVisible();
+28 -11
View File
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
async function signInAsAdmin(page: Page) { async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin"); await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="email"]', "admin@nexus.dev");
await page.fill('input[type="password"]', "admin123"); await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/); await expect(page).toHaveURL(/\/(dashboard|resources)/);
@@ -27,9 +27,9 @@ test.describe("Vacations", () => {
test("request vacation button is visible", async ({ page }) => { test("request vacation button is visible", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect( await expect(page.locator("button", { hasText: /Request Vacation/i })).toBeVisible({
page.locator("button", { hasText: /Request Vacation/i }), timeout: 10000,
).toBeVisible({ timeout: 10000 }); });
}); });
test("request vacation is blocked without linked resource", async ({ page }) => { 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 }); const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
await expect(reqBtn).toBeDisabled(); await expect(reqBtn).toBeDisabled();
await expect( 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 }); ).toBeVisible({ timeout: 5000 });
}); });
}); });
@@ -57,11 +59,18 @@ test.describe("Vacations", () => {
test("team calendar tab renders", async ({ page }) => { test("team calendar tab renders", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.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); await page.waitForTimeout(500);
// Calendar view should appear // Calendar view should appear
await expect( 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 }); ).toBeVisible({ timeout: 10000 });
}); });
@@ -75,11 +84,15 @@ test.describe("Vacations", () => {
await expect(filters.nth(2)).toHaveValue(""); await expect(filters.nth(2)).toHaveValue("");
}); });
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => { test("vacation request preview excludes regional public holidays from deducted days", async ({
page,
}) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /request vacation/i }).click(); await page.getByRole("button", { name: /request vacation/i }).click();
await expect(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(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
await page.getByLabel(/^type/i).selectOption("ANNUAL"); await page.getByLabel(/^type/i).selectOption("ANNUAL");
await fillDisplayDate(page, /start date/i, "2026-01-06"); await fillDisplayDate(page, /start date/i, "2026-01-06");
@@ -89,9 +102,13 @@ test.describe("Vacations", () => {
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1"); await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0"); await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0"); await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("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-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[]} */ /** @type {import("eslint").Linter.FlatConfig[]} */
export default [ export default [
+6 -6
View File
@@ -11,16 +11,16 @@ const nextConfig: NextConfig = {
"recharts", "recharts",
"date-fns", "date-fns",
"framer-motion", "framer-motion",
"@capakraken/shared", "@nexus/shared",
"@react-pdf/renderer", "@react-pdf/renderer",
], ],
}, },
transpilePackages: [ transpilePackages: [
"@capakraken/api", "@nexus/api",
"@capakraken/db", "@nexus/db",
"@capakraken/engine", "@nexus/engine",
"@capakraken/shared", "@nexus/shared",
"@capakraken/staffing", "@nexus/staffing",
], ],
typedRoutes: true, typedRoutes: true,
eslint: { eslint: {
+9 -9
View File
@@ -1,5 +1,5 @@
{ {
"name": "@capakraken/web", "name": "@nexus/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -13,11 +13,11 @@
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts" "test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
}, },
"dependencies": { "dependencies": {
"@capakraken/api": "workspace:*", "@nexus/api": "workspace:*",
"@capakraken/application": "workspace:*", "@nexus/application": "workspace:*",
"@capakraken/db": "workspace:*", "@nexus/db": "workspace:*",
"@capakraken/engine": "workspace:*", "@nexus/engine": "workspace:*",
"@capakraken/shared": "workspace:*", "@nexus/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -34,7 +34,7 @@
"dompurify": "^3.4.0", "dompurify": "^3.4.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"next": "^15.5.15", "next": "^15.5.16",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -51,8 +51,8 @@
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "^16.2.3", "@next/bundle-analyzer": "^16.2.3",
"@axe-core/playwright": "^4.11.1", "@axe-core/playwright": "^4.11.1",
"@capakraken/eslint-config": "workspace:*", "@nexus/eslint-config": "workspace:*",
"@capakraken/tsconfig": "workspace:*", "@nexus/tsconfig": "workspace:*",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
+1 -1
View File
@@ -6,7 +6,7 @@
* dev server at localhost:3100 and exercises real dev-DB data. * dev server at localhost:3100 and exercises real dev-DB data.
* *
* Usage: * Usage:
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts * pnpm --filter @nexus/web exec playwright test --config playwright.dev.config.ts
* *
* Prerequisites: * Prerequisites:
* - Dev server running: pnpm run dev (or docker compose up) * - Dev server running: pnpm run dev (or docker compose up)
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "CapaKraken — Resource & Capacity Planning", "name": "Nexus — Resource & Capacity Planning",
"short_name": "CapaKraken", "short_name": "Nexus",
"description": "Resource planning and project staffing for 3D production", "description": "Resource planning and project staffing for 3D production",
"start_url": "/dashboard", "start_url": "/dashboard",
"display": "standalone", "display": "standalone",
+3 -3
View File
@@ -1,6 +1,6 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
const CACHE_NAME = "capakraken-v2"; const CACHE_NAME = "nexus-v2";
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/; const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
// Offline fallback page (simple inline HTML) // Offline fallback page (simple inline HTML)
@@ -9,7 +9,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CapaKraken - Offline</title> <title>Nexus - Offline</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -31,7 +31,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
<body> <body>
<div class="container"> <div class="container">
<h1>You are offline</h1> <h1>You are offline</h1>
<p>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> <button onclick="location.reload()">Retry</button>
</div> </div>
</body> </body>
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js"; import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js"; import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
export const metadata = { title: "Vacation Management — CapaKraken" }; export const metadata = { title: "Vacation Management — Nexus" };
export default function AdminVacationsPage() { export default function AdminVacationsPage() {
return ( return (
@@ -10,15 +10,19 @@ export default function AdminVacationsPage() {
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1> <h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe. Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
Fallback-Importe.
</p> </p>
</div> </div>
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">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"> <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> </p>
</div> </div>
<HolidayCalendarEditor /> <HolidayCalendarEditor />
@@ -26,9 +30,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">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"> <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> </p>
</div> </div>
<PublicHolidayBatch /> <PublicHolidayBatch />
@@ -36,9 +43,12 @@ export default function AdminVacationsPage() {
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Entitlements
</h2>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden. Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
aufgeloest wurden.
</p> </p>
</div> </div>
<EntitlementManager /> <EntitlementManager />
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared"; import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js"; import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -122,7 +122,8 @@ function EstimateDetailPanel({
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Estimate detail <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> </p>
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50"> <h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
{estimate.name} {estimate.name}
@@ -206,7 +207,8 @@ function EstimateDetailPanel({
<section> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Scope items <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> </h3>
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span> <span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
</div> </div>
@@ -239,7 +241,8 @@ function EstimateDetailPanel({
<section> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Demand lines <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> </h3>
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span> <span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
</div> </div>
@@ -345,13 +348,19 @@ function EstimateCard({
<div className="mt-5 grid gap-3 sm:grid-cols-2"> <div className="mt-5 grid gap-3 sm:grid-cols-2">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">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"> <p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{estimate.opportunityId ?? "Not set"} {estimate.opportunityId ?? "Not set"}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">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"> <p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
{formatDateLong(estimate.updatedAt)} {formatDateLong(estimate.updatedAt)}
</p> </p>
@@ -466,7 +475,7 @@ export function EstimatesClient() {
No estimates yet No estimates yet
</p> </p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500"> <p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
Start with the wizard to create a connected estimate from CapaKraken data. Start with the wizard to create a connected estimate from Nexus data.
</p> </p>
</div> </div>
) : ( ) : (
+1 -1
View File
@@ -1,7 +1,7 @@
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js"; import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
export const metadata = { export const metadata = {
title: "CapaKraken — Mobile Summary", title: "Nexus — Mobile Summary",
}; };
export default function MobilePage() { export default function MobilePage() {
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { formatDate, formatMoney } from "~/lib/format.js"; import { formatDate, formatMoney } from "~/lib/format.js";
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared"; import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared"; import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { clsx } from "clsx"; import { clsx } from "clsx";
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useUrlFilters } from "~/hooks/useUrlFilters.js"; import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import Link from "next/link"; import Link from "next/link";
import type { Resource, SkillEntry } from "@capakraken/shared"; import type { Resource, SkillEntry } from "@nexus/shared";
import { RESOURCE_COLUMNS } from "@capakraken/shared"; import { RESOURCE_COLUMNS } from "@nexus/shared";
import { BlueprintTarget, ResourceType } from "@capakraken/shared"; import { BlueprintTarget, ResourceType } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatMoney } from "~/lib/format.js"; import { formatMoney } from "~/lib/format.js";
import { generateCsv, downloadCsv } from "~/lib/csv-export.js"; import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
@@ -945,7 +945,7 @@ export function ResourcesClient() {
sortField={sortField} sortField={sortField}
sortDir={sortDir} sortDir={sortDir}
onSort={toggle} onSort={toggle}
tooltip="Unique employee identifier used across all CapaKraken records." tooltip="Unique employee identifier used across all Nexus records."
/> />
); );
case "displayName": case "displayName":
+8 -10
View File
@@ -2,24 +2,22 @@ import type { Metadata } from "next";
import { createCaller } from "~/server/trpc.js"; import { createCaller } from "~/server/trpc.js";
import { ResourceDetail } from "~/components/resources/ResourceDetail.js"; import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
export async function generateMetadata( export async function generateMetadata({
{ params }: { params: Promise<{ id: string }> }, params,
): Promise<Metadata> { }: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params; const { id } = await params;
try { try {
const trpc = await createCaller(); const trpc = await createCaller();
const resource = await trpc.resource.getById({ id }); const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | CapaKraken` }; return { title: `${resource.displayName} — Resources | Nexus` };
} catch { } catch {
return { title: "Resource — CapaKraken" }; return { title: "Resource — Nexus" };
} }
} }
export default async function ResourceDetailPage({ export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; const { id } = await params;
return <ResourceDetail resourceId={id} />; return <ResourceDetail resourceId={id} />;
} }
@@ -1,5 +1,5 @@
import type { Resource } from "@capakraken/shared"; import type { Resource } from "@nexus/shared";
import { ResourceType } from "@capakraken/shared"; import { ResourceType } from "@nexus/shared";
export type ModalState = export type ModalState =
| { type: "closed" } | { type: "closed" }
+1 -1
View File
@@ -1,6 +1,6 @@
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js"; import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
export const metadata = { title: "My Vacations — CapaKraken" }; export const metadata = { title: "My Vacations — Nexus" };
export default function MyVacationsPage() { export default function MyVacationsPage() {
return <MyVacationsClient />; return <MyVacationsClient />;
@@ -1,4 +1,4 @@
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
/** Window over which auth events are analysed. */ /** Window over which auth events are analysed. */
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
const auditLogFindManyMock = vi.hoisted(() => vi.fn()); const auditLogFindManyMock = vi.hoisted(() => vi.fn());
const userFindManyMock = vi.hoisted(() => vi.fn()); const userFindManyMock = vi.hoisted(() => vi.fn());
vi.mock("@capakraken/db", () => ({ vi.mock("@nexus/db", () => ({
prisma: { prisma: {
auditLog: { findMany: auditLogFindManyMock }, auditLog: { findMany: auditLogFindManyMock },
user: { findMany: userFindManyMock }, user: { findMany: userFindManyMock },
@@ -27,11 +27,11 @@ vi.mock("@capakraken/db", () => ({
// ─── createNotificationsForUsers mock ───────────────────────────────────────── // ─── createNotificationsForUsers mock ─────────────────────────────────────────
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@capakraken/api", () => ({ vi.mock("@nexus/api", () => ({
createNotificationsForUsers: createNotificationsMock, createNotificationsForUsers: createNotificationsMock,
})); }));
vi.mock("@capakraken/api/lib/logger", () => ({ vi.mock("@nexus/api/lib/logger", () => ({
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, 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 { GET } = await importRoute();
const res = await GET(makeRequest()); const res = await GET(makeRequest());
expect(res.status).toBe(401); 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 () => { it("proceeds when verifyCronSecret returns null (allowed)", async () => {
verifyCronSecretMock.mockReturnValue(null); verifyCronSecretMock.mockReturnValue(null);
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@capakraken/api"; import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
import { detectAuthAnomalies } from "./detect.js"; import { detectAuthAnomalies } from "./detect.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { checkChargeabilityAlerts } from "@capakraken/api"; import { checkChargeabilityAlerts } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { checkPendingEstimateReminders } from "@capakraken/api"; import { checkPendingEstimateReminders } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@capakraken/api"; import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { createConnection } from "net"; import { createConnection } from "net";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { autoImportPublicHolidays } from "@capakraken/api"; import { autoImportPublicHolidays } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
skippedExisting: result.skippedExisting, skippedExisting: result.skippedExisting,
}); });
} catch (error) { } catch (error) {
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed"); logger.error(
return NextResponse.json( { error, route: "/api/cron/public-holidays", year },
{ ok: false, error: "Internal error" }, "Public holiday import cron failed",
{ status: 500 },
); );
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
} }
} }
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { createNotificationsForUsers } from "@capakraken/api"; import { createNotificationsForUsers } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { sendWeeklyDigest } from "@capakraken/api"; import { sendWeeklyDigest } from "@nexus/api";
import { logger } from "@capakraken/api/lib/logger"; import { logger } from "@nexus/api/lib/logger";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+9 -3
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { createConnection } from "net"; import { createConnection } from "net";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -30,8 +30,14 @@ async function checkRedis(): Promise<"ok" | "error"> {
socket.destroy(); socket.destroy();
resolve(data.toString().includes("PONG") ? "ok" : "error"); resolve(data.toString().includes("PONG") ? "ok" : "error");
}); });
socket.on("timeout", () => { socket.destroy(); resolve("error"); }); socket.on("timeout", () => {
socket.on("error", () => { socket.destroy(); resolve("error"); }); socket.destroy();
resolve("error");
});
socket.on("error", () => {
socket.destroy();
resolve("error");
});
} catch { } catch {
resolve("error"); resolve("error");
} }
+7 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/api/sse", () => ({ vi.mock("@nexus/api/sse", () => ({
eventBus: { subscriberCount: 0 }, eventBus: { subscriberCount: 0 },
})); }));
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request); const response = await GET(request);
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown }; const body = (await response.json()) as { timestamp: string; uptime: unknown; memory: unknown };
expect(typeof body.timestamp).toBe("string"); expect(typeof body.timestamp).toBe("string");
expect(body.uptime).toBeDefined(); expect(body.uptime).toBeDefined();
expect(body.memory).toBeDefined(); expect(body.memory).toBeDefined();
@@ -81,7 +81,11 @@ describe("GET /api/perf — security hardening", () => {
const response = await GET(request); const response = await GET(request);
expect(response.status).toBe(401); expect(response.status).toBe(401);
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown }; const body = (await response.json()) as {
error?: string;
timestamp?: string;
memory?: unknown;
};
expect(body.timestamp).toBeUndefined(); expect(body.timestamp).toBeUndefined();
expect(body.memory).toBeUndefined(); expect(body.memory).toBeUndefined();
}); });
+1 -1
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { eventBus } from "@capakraken/api/sse"; import { eventBus } from "@nexus/api/sse";
import { verifyCronSecret } from "~/lib/cron-auth.js"; import { verifyCronSecret } from "~/lib/cron-auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+3 -6
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { createConnection } from "net"; import { createConnection } from "net";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
/** /**
* Lightweight Redis PING check using a raw TCP socket. * Lightweight Redis PING check using a raw TCP socket.
* Avoids importing ioredis (which is only a dependency of @capakraken/api). * Avoids importing ioredis (which is only a dependency of @nexus/api).
*/ */
async function checkRedis(): Promise<"ok" | "error"> { async function checkRedis(): Promise<"ok" | "error"> {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -58,10 +58,7 @@ async function checkRedis(): Promise<"ok" | "error"> {
} }
export async function GET() { export async function GET() {
const [postgres, redis] = await Promise.all([ const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
checkPostgres(),
checkRedis(),
]);
const allHealthy = postgres === "ok" && redis === "ok"; const allHealthy = postgres === "ok" && redis === "ok";
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
vi.mock("~/server/auth.js", () => ({ auth: authMock })); vi.mock("~/server/auth.js", () => ({ auth: authMock }));
// ─── heavy dep stubs ───────────────────────────────────────────────────────── // ─── heavy dep stubs ─────────────────────────────────────────────────────────
vi.mock("@capakraken/db", () => ({ vi.mock("@nexus/db", () => ({
prisma: { prisma: {
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) }, demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
assignment: { findMany: vi.fn().mockResolvedValue([]) }, assignment: { findMany: vi.fn().mockResolvedValue([]) },
@@ -21,11 +21,11 @@ vi.mock("@capakraken/db", () => ({
}, },
})); }));
vi.mock("@capakraken/application", () => ({ vi.mock("@nexus/application", () => ({
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }), buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
})); }));
vi.mock("@capakraken/api", () => ({ vi.mock("@nexus/api", () => ({
anonymizeResource: vi.fn((r: unknown) => r), anonymizeResource: vi.fn((r: unknown) => r),
getAnonymizationDirectory: vi.fn().mockResolvedValue({}), getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
})); }));
@@ -2,10 +2,10 @@ import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react"; import { createElement } from "react";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { buildSplitAllocationReadModel } from "@capakraken/application"; import { buildSplitAllocationReadModel } from "@nexus/application";
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api"; import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import type { AllocationLike } from "@capakraken/shared"; import type { AllocationLike } from "@nexus/shared";
import { auth } from "~/server/auth.js"; import { auth } from "~/server/auth.js";
import { AllocationReport } from "~/components/reports/AllocationReport.js"; import { AllocationReport } from "~/components/reports/AllocationReport.js";
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js"; import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
+6 -6
View File
@@ -1,9 +1,9 @@
import { loadRoleDefaults } from "@capakraken/api"; import { loadRoleDefaults } from "@nexus/api";
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse"; import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler"; import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import type { SystemRole } from "@capakraken/shared"; import type { SystemRole } from "@nexus/shared";
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared"; import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
import { auth } from "~/server/auth.js"; import { auth } from "~/server/auth.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+3 -3
View File
@@ -1,6 +1,6 @@
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api"; import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
import { appRouter } from "@capakraken/api/router"; import { appRouter } from "@nexus/api/router";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
@@ -2,7 +2,7 @@
import { use, useState } from "react"; import { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared"; import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) { export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
+2 -2
View File
@@ -98,7 +98,7 @@ export default function SignInPage() {
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60"> <div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
<div> <div>
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300"> <span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
CapaKraken Control Center Nexus Control Center
</span> </span>
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50"> <h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
Resource planning that stays readable under pressure. Resource planning that stays readable under pressure.
@@ -137,7 +137,7 @@ export default function SignInPage() {
Welcome Back Welcome Back
</p> </p>
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50"> <h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"} {mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
</h2> </h2>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
{mfaRequired {mfaRequired
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, use } from "react"; import { useState, use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared"; import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) { export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
@@ -91,7 +91,7 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
You have been invited as <strong>{invite.role}</strong> to 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>). activate your account (<span className="font-medium">{invite.email}</span>).
</p> </p>
</div> </div>
+51 -10
View File
@@ -19,8 +19,8 @@ const displayFont = Manrope({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"), metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
title: "CapaKraken — Resource & Capacity Planning", title: "Nexus — Resource & Capacity Planning",
description: "Interactive resource planning and project staffing tool", description: "Interactive resource planning and project staffing tool",
manifest: "/manifest.json", manifest: "/manifest.json",
icons: { icons: {
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: "default", statusBarStyle: "default",
title: "CapaKraken", title: "Nexus",
}, },
openGraph: { openGraph: {
title: "CapaKraken — Resource & Capacity Planning", title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.", description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }], images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
type: "website", type: "website",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "CapaKraken — Resource & Capacity Planning", title: "Nexus — Resource & Capacity Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.", description: "Estimates, staffing, chargeability, and timelines in one workspace.",
images: ["/og-image.png"], images: ["/og-image.png"],
}, },
@@ -60,15 +60,56 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: ` <script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
try { 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.mode === 'dark') document.documentElement.classList.add('dark');
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent); if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
} catch(e) {} } catch(e) {}
`}} /> `,
}}
/>
</head> </head>
<body 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> <TRPCProvider>{children}</TRPCProvider>
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
<InstallPrompt /> <InstallPrompt />
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared"; import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
import { createFirstAdmin } from "./actions.js"; import { createFirstAdmin } from "./actions.js";
export function SetupClient() { export function SetupClient() {
@@ -76,7 +76,7 @@ export function SetupClient() {
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Create the initial administrator account for CapaKraken. Create the initial administrator account for Nexus.
</p> </p>
</div> </div>
+3 -7
View File
@@ -1,11 +1,7 @@
"use server"; "use server";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { SystemRole } from "@capakraken/db"; import { SystemRole } from "@nexus/db";
import { import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
export type SetupResult = export type SetupResult =
| { success: true } | { success: true }
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "@capakraken/db"; import { prisma } from "@nexus/db";
import { SetupClient } from "./SetupClient.js"; import { SetupClient } from "./SetupClient.js";
export default async function SetupPage() { export default async function SetupPage() {
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js"; import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
import { assertSpreadsheetFile } from "~/lib/excel.js"; import { assertSpreadsheetFile } from "~/lib/excel.js";
import type { SkillEntry } from "@capakraken/shared"; import type { SkillEntry } from "@nexus/shared";
interface ParsedEntry { interface ParsedEntry {
fileName: string; fileName: string;
candidateEid: string; // guessed from filename (no extension, lowercased) candidateEid: string; // guessed from filename (no extension, lowercased)
selectedEid: string; selectedEid: string;
skills: SkillEntry[]; skills: SkillEntry[];
employeeInfo: Record<string, string>; employeeInfo: Record<string, string>;
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
); );
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({ const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
onSuccess: (data) => { setResult(data); setSubmitting(false); }, onSuccess: (data) => {
onError: (err) => { setError(err.message); setSubmitting(false); }, setResult(data);
setSubmitting(false);
},
onError: (err) => {
setError(err.message);
setSubmitting(false);
},
}); });
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) { async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
const empInfo: Record<string, string> = {}; const empInfo: Record<string, string> = {};
if (roleId) empInfo["roleId"] = roleId; if (roleId) empInfo["roleId"] = roleId;
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl; if (result.employeeInfo.portfolioUrl)
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
return { return {
fileName: file.name, fileName: file.name,
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
skills: e.skills, skills: e.skills,
employeeInfo: { employeeInfo: {
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}), ...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}), ...(e.employeeInfo["portfolioUrl"]
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
: {}),
}, },
})), })),
}); });
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
return ( return (
<div className="p-6 max-w-4xl"> <div className="p-6 max-w-4xl">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">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"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Upload multiple skill matrix files at once. Files are matched to resources by filename. Upload multiple skill matrix files at once. Files are matched to resources by filename.
</p> </p>
@@ -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" className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
> >
<svg 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"> <svg
<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" /> 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> </svg>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p> <p className="text-sm font-medium text-gray-700 dark:text-gray-300">
<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> Click to select multiple .xlsx files
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} /> </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> </div>
{/* Summary */} {/* Summary */}
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
</div> </div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm"> <div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span> <span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span> <span className="text-yellow-600 dark:text-yellow-400 ml-1">
unmatched (select EID manually)
</span>
</div> </div>
</div> </div>
)} )}
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700"> <thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th> File
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th> </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-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th> 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> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700"> <tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{entries.map((entry, idx) => ( {entries.map((entry, idx) => (
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}> <tr
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td> 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"> <td className="px-4 py-3">
{entry.status === "matched" ? ( {entry.status === "matched" ? (
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span> <span className="font-mono text-sm text-gray-800 dark:text-gray-100">
{entry.selectedEid}
</span>
) : ( ) : (
<select <select
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500" className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
> >
<option value=""> Select resource </option> <option value=""> Select resource </option>
{resourceList.map((r) => ( {resourceList.map((r) => (
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option> <option key={r.eid} value={r.eid}>
{r.displayName} ({r.eid})
</option>
))} ))}
</select> </select>
)} )}
</td> </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-right text-gray-700 dark:text-gray-300">
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td> {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"> <td className="px-4 py-3 text-center">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${ <span
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" 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} {entry.status}
</span> </span>
</td> </td>
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
)} )}
{error && ( {error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{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 && ( {result && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400"> <div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found. Import complete: <strong>{result.updated}</strong> updated,{" "}
<strong>{result.notFound}</strong> not found.
</div> </div>
)} )}
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
disabled={submitting || matched === 0} disabled={submitting || matched === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
> >
{submitting ? "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> </button>
)} )}
</div> </div>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { SystemRole } from "@capakraken/shared"; import { SystemRole } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!email) { setError("Email is required."); return; } if (!email) {
setError("Email is required.");
return;
}
await inviteMutation.mutateAsync({ email, role }); 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" className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
> >
{ROLE_OPTIONS.map((opt) => ( {ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>
{opt.label}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { PermissionKey } from "@capakraken/shared"; import { PermissionKey } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -1,6 +1,6 @@
"use client"; "use client";
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js"; import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
@@ -1,4 +1,4 @@
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared"; import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = { const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -1,4 +1,4 @@
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared"; import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey); const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
@@ -1,13 +1,13 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import type { PermissionKey } from "@capakraken/shared"; import type { PermissionKey } from "@nexus/shared";
import { import {
SystemRole, SystemRole,
ROLE_DEFAULT_PERMISSIONS, ROLE_DEFAULT_PERMISSIONS,
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
type PermissionOverrides, type PermissionOverrides,
} from "@capakraken/shared"; } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { InviteUserModal } from "./InviteUserModal.js"; import { InviteUserModal } from "./InviteUserModal.js";
@@ -176,7 +176,7 @@ export function WebhooksClient() {
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Configure outbound webhooks to notify external services about events in CapaKraken. Configure outbound webhooks to notify external services about events in Nexus.
</p> </p>
</div> </div>
<button className={PRIMARY_BUTTON} onClick={openCreateModal}> <button className={PRIMARY_BUTTON} onClick={openCreateModal}>
@@ -194,10 +194,7 @@ export function WebhooksClient() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{webhooks.map((wh) => ( {webhooks.map((wh) => (
<div <div key={wh.id} className="app-surface flex items-center gap-4 p-4">
key={wh.id}
className="app-surface flex items-center gap-4 p-4"
>
{/* Active indicator */} {/* Active indicator */}
<div <div
className={`h-3 w-3 shrink-0 rounded-full ${ className={`h-3 w-3 shrink-0 rounded-full ${
@@ -209,9 +206,7 @@ export function WebhooksClient() {
{/* Info */} {/* Info */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white"> <span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
{wh.name}
</span>
{wh.url.includes("hooks.slack.com") && ( {wh.url.includes("hooks.slack.com") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"> <span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
Slack Slack
@@ -257,17 +252,12 @@ export function WebhooksClient() {
</button> </button>
<button <button
className={SECONDARY_BUTTON} className={SECONDARY_BUTTON}
onClick={() => onClick={() => handleToggleActive(wh.id, wh.isActive)}
handleToggleActive(wh.id, wh.isActive)
}
disabled={updateMut.isPending} disabled={updateMut.isPending}
> >
{wh.isActive ? "Disable" : "Enable"} {wh.isActive ? "Disable" : "Enable"}
</button> </button>
<button <button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
className={SECONDARY_BUTTON}
onClick={() => openEditModal(wh)}
>
Edit Edit
</button> </button>
{deleteConfirmId === wh.id ? ( {deleteConfirmId === wh.id ? (
@@ -282,18 +272,12 @@ export function WebhooksClient() {
> >
Confirm Confirm
</button> </button>
<button <button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(null)}
>
Cancel Cancel
</button> </button>
</div> </div>
) : ( ) : (
<button <button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
className={SECONDARY_BUTTON}
onClick={() => setDeleteConfirmId(wh.id)}
>
Delete Delete
</button> </button>
)} )}
@@ -335,9 +319,7 @@ export function WebhooksClient() {
{/* Secret */} {/* Secret */}
<div> <div>
<label className={LABEL_CLASS}> <label className={LABEL_CLASS}>Secret (optional)</label>
Secret (optional)
</label>
<input <input
className={INPUT_CLASS} className={INPUT_CLASS}
type="password" 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { import {
INPUT_CLASS, INPUT_CLASS,
@@ -123,7 +123,9 @@ export function AiProviderPanel({
</p> </p>
) : null} ) : null}
{urlParsedType === "completions" ? ( {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} ) : null}
</div> </div>
@@ -154,7 +156,7 @@ export function AiProviderPanel({
id="ai-model" id="ai-model"
type="text" type="text"
className={INPUT_CLASS} 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} value={model}
onChange={(event) => onModelChange(event.target.value)} onChange={(event) => onModelChange(event.target.value)}
/> />
@@ -223,12 +225,7 @@ export function AiProviderPanel({
) : null} ) : null}
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
<button <button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
{isSaving ? "Saving…" : "Save Settings"} {isSaving ? "Saving…" : "Save Settings"}
</button> </button>
<button <button
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
</div> </div>
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
<button <button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
type="button"
onClick={onSave}
disabled={isSaving}
className={PRIMARY_BUTTON_CLASS}
>
{isSaving ? "Saving…" : "Save Settings"} {isSaving ? "Saving…" : "Save Settings"}
</button> </button>
{saved ? ( {saved ? (
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpFrom} value={smtpFrom}
onChange={(event) => setSmtpFrom(event.target.value)} onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app" placeholder="noreply@nexus.app"
/> />
</div> </div>
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}> <div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared"; import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared"; import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import type { CollapsedAllocationGroups } from "./allocationGroupState.js"; import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
import { formatDate } from "~/lib/format.js"; import { formatDate } from "~/lib/format.js";
import { AllocationRow } from "./AllocationRow.js"; import { AllocationRow } from "./AllocationRow.js";
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
import { useDebounce } from "~/hooks/useDebounce.js"; import { useDebounce } from "~/hooks/useDebounce.js";
import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationStatus } from "@capakraken/shared"; import { AllocationStatus } from "@nexus/shared";
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared"; import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { toDateInputValue } from "~/lib/format.js"; import { toDateInputValue } from "~/lib/format.js";
@@ -26,7 +26,8 @@ interface AllocationModalProps {
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) { export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
const isEditing = Boolean(allocation); const isEditing = Boolean(allocation);
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment"; const initialEntryKind: EntryKind =
allocation && !allocation.resourceId ? "demand" : "assignment";
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind); const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
const isDemandEntry = entryKind === "demand"; const isDemandEntry = entryKind === "demand";
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{ isActive: true, limit: 500 }, { isActive: true, limit: 500 },
{ staleTime: 60_000 }, { staleTime: 60_000 },
); );
const { data: projects } = trpc.project.list.useQuery( const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
{ limit: 500 }, const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
{ staleTime: 60_000 },
);
const { data: rolesData } = trpc.role.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
// Fetch existing allocations for the selected resource+project to detect overlaps // Fetch existing allocations for the selected resource+project to detect overlaps
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId; const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const shouldCheckConflicts = const shouldCheckConflicts =
!isDemandEntry && !isDemandEntry &&
!!debouncedResourceId && !!debouncedResourceId &&
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) && conflictCheckStart !== null &&
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) && !isNaN(conflictCheckStart.getTime()) &&
conflictCheckEnd !== null &&
!isNaN(conflictCheckEnd.getTime()) &&
debouncedHoursPerDay > 0; debouncedHoursPerDay > 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: conflictResult, isFetching: checkingConflicts } =
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)( // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ (trpc.allocation.checkConflicts.useQuery as any)(
resourceId: debouncedResourceId, {
startDate: conflictCheckStart, resourceId: debouncedResourceId,
endDate: conflictCheckEnd, startDate: conflictCheckStart,
hoursPerDay: debouncedHoursPerDay, endDate: conflictCheckEnd,
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined, hoursPerDay: debouncedHoursPerDay,
}, excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
{ enabled: shouldCheckConflicts, staleTime: 15_000 }, },
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean }; { enabled: shouldCheckConflicts, staleTime: 15_000 },
) as {
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
isFetching: boolean;
};
const overlapWarning = useMemo(() => { const overlapWarning = useMemo(() => {
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null; if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
const formEnd = new Date(endDate); const formEnd = new Date(endDate);
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null; if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
const allocList = (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) { for (const existing of allocList) {
// Skip the allocation being edited // Skip the allocation being edited
if (isEditing && allocation && existing.id === allocation.id) continue; if (isEditing && allocation && existing.id === allocation.id) continue;
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
} }
} }
return null; return null;
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]); }, [
shouldCheckOverlap,
existingAllocations,
startDate,
endDate,
isEditing,
allocation,
resourceId,
]);
const invalidatePlanningViews = useInvalidatePlanningViews(); const invalidatePlanningViews = useInvalidatePlanningViews();
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
useEffect(() => { useEffect(() => {
setServerError(null); setServerError(null);
setOverbookingAcknowledged(false); setOverbookingAcknowledged(false);
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]); }, [
resourceId,
projectId,
roleId,
roleFreeText,
startDate,
endDate,
hoursPerDay,
status,
entryKind,
]);
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
// Determine role string from roleId if set // Determine role string from roleId if set
const rolesList = rolesData ?? []; const rolesList = rolesData ?? [];
const selectedRole = rolesList.find((r) => r.id === roleId); const selectedRole = rolesList.find((r) => r.id === roleId);
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined); const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100)); const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
updateMutation.mutate({ updateMutation.mutate({
id: getPlanningEntryMutationId(allocation), id: getPlanningEntryMutationId(allocation),
data: { data: {
resourceId: isDemandEntry ? undefined : (resourceId || undefined), resourceId: isDemandEntry ? undefined : resourceId || undefined,
projectId, projectId,
role: roleString, role: roleString,
roleId: roleId || undefined, roleId: roleId || undefined,
headcount: isDemandEntry ? headcount : 1, headcount: isDemandEntry ? headcount : 1,
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}), ...(isDemandEntry && budgetEur
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
: {}),
startDate: start, startDate: start,
endDate: end, endDate: end,
hoursPerDay, 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"; "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; const resourceList = (resources?.resources ?? []) as Array<{
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>; 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 rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment"; const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
return ( return (
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4"> <AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
<div <div role="dialog" aria-modal="true" data-testid="allocation-modal">
role="dialog"
aria-modal="true"
data-testid="allocation-modal"
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{isDemandEntry && ( {isDemandEntry && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label> <label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Headcount:
</label>
<input <input
type="number" type="number"
value={headcount} value={headcount}
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label> <label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
Budget (EUR):
</label>
<input <input
type="number" type="number"
value={budgetEur} value={budgetEur}
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{!isDemandEntry && ( {!isDemandEntry && (
<div> <div>
<label htmlFor="modal-resource" className={labelClass}> <label htmlFor="modal-resource" className={labelClass}>
Resource <span className="text-red-500">*</span><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> </label>
<select <select
id="modal-resource" id="modal-resource"
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Project */} {/* Project */}
<div> <div>
<label htmlFor="modal-project" className={labelClass}> <label htmlFor="modal-project" className={labelClass}>
Project <span className="text-red-500">*</span><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> </label>
<select <select
id="modal-project" id="modal-project"
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Role */} {/* Role */}
<div> <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 <select
id="modal-role" id="modal-role"
value={roleId} value={roleId}
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
{/* Dates */} {/* Dates */}
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span> <span className={labelClass}>
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} /> Date Range <span className="text-red-500">*</span>
</span>
<DateRangePresets
onSelect={(s, e) => {
setStartDate(s);
setEndDate(e);
}}
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="modal-start" className={labelClass}> <label htmlFor="modal-start" className={labelClass}>
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." /> Start Date{" "}
</label> <InfoTooltip content="First day of this allocation period (inclusive)." />
<DateInput </label>
id="modal-start" <DateInput
value={startDate} id="modal-start"
onChange={setStartDate} value={startDate}
className={inputClass} onChange={setStartDate}
required className={inputClass}
/> required
</div> />
<div> </div>
<label htmlFor="modal-end" className={labelClass}> <div>
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." /> <label htmlFor="modal-end" className={labelClass}>
</label> End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
<DateInput </label>
id="modal-end" <DateInput
value={endDate} id="modal-end"
onChange={setEndDate} value={endDate}
min={startDate} onChange={setEndDate}
className={inputClass} min={startDate}
required className={inputClass}
/> required
</div> />
</div>
</div> </div>
</div> </div>
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label htmlFor="modal-hours" className={labelClass}> <label htmlFor="modal-hours" className={labelClass}>
Hours / Day<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> </label>
<input <input
id="modal-hours" id="modal-hours"
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
</div> </div>
<div> <div>
<label htmlFor="modal-status" className={labelClass}> <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> </label>
<select <select
id="modal-status" id="modal-status"
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
}} }}
className="rounded border-gray-300 dark:border-gray-600" className="rounded border-gray-300 dark:border-gray-600"
/> />
<span className="font-medium text-gray-700 dark:text-gray-300">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> </label>
{isRecurring && ( {isRecurring && (
<div className="mt-2"> <div className="mt-2">
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
)} )}
{!conflictResult && checkingConflicts && ( {!conflictResult && checkingConflicts && (
<ConflictWarningPanel <ConflictWarningPanel
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }} result={{
isOverbooking: false,
overbooking: null,
vacationOverlap: [],
hasVacationOverlap: false,
}}
isLoading={true} isLoading={true}
acknowledged={false} acknowledged={false}
onAcknowledge={() => {}} onAcknowledge={() => {}}
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
<button <button
type="submit" type="submit"
disabled={isPending || hasUnacknowledgedOverbooking} 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" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
> >
{isPending ? "Saving…" : "Save"} {isPending ? "Saving…" : "Save"}
@@ -1,4 +1,4 @@
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared"; import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
const STATUS_LEFT_BORDER: Record<string, string> = { const STATUS_LEFT_BORDER: Record<string, string> = {
@@ -13,8 +13,8 @@ import type {
AllocationWithDetails, AllocationWithDetails,
ColumnDef, ColumnDef,
AllocationStatus, AllocationStatus,
} from "@capakraken/shared"; } from "@nexus/shared";
import { ALLOCATION_COLUMNS } from "@capakraken/shared"; import { ALLOCATION_COLUMNS } from "@nexus/shared";
import { useSelection } from "~/hooks/useSelection.js"; import { useSelection } from "~/hooks/useSelection.js";
import { FilterBar } from "~/components/ui/FilterBar.js"; import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js"; import { FilterChips } from "~/components/ui/FilterChips.js";
@@ -328,7 +328,7 @@ export function AllocationsClient() {
// ─── View mode: grouped (default) vs flat ────────────────────────────────── // ─── View mode: grouped (default) vs flat ──────────────────────────────────
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">( const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
"capakraken:allocations:viewMode", "nexus:allocations:viewMode",
"grouped", "grouped",
); );
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() => const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { AllocationConflictCheckResult } from "@capakraken/shared"; import type { AllocationConflictCheckResult } from "@nexus/shared";
const INITIAL_ROWS_SHOWN = 5; const INITIAL_ROWS_SHOWN = 5;
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm"> <div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
<p className="font-semibold text-amber-800 dark:text-amber-300"> <p className="font-semibold text-amber-800 dark:text-amber-300">
Overbooking on {result.overbooking.totalConflictDays} day Overbooking on {result.overbooking.totalConflictDays} day
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} {result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity) {result.overbooking.maxOverbookPercent}% over capacity)
</p> </p>
<p className="mt-1 text-amber-700 dark:text-amber-400"> <p className="mt-1 text-amber-700 dark:text-amber-400">
The resource already has allocations that exceed their daily capacity on the following days. The resource already has allocations that exceed their daily capacity on the following
You can still save check the box below to confirm. days. You can still save check the box below to confirm.
</p> </p>
{/* Day-by-day table */} {/* Day-by-day table */}
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
</thead> </thead>
<tbody> <tbody>
{visibleDays.map((day) => ( {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">{day.date}</td>
<td className="py-1 pr-4 text-right">{day.availableHours}h</td> <td className="py-1 pr-4 text-right">{day.availableHours}h</td>
<td className="py-1 pr-4 text-right">{day.existingHours}h</td> <td className="py-1 pr-4 text-right">{day.existingHours}h</td>
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
onClick={() => setShowAllDays((v) => !v)} onClick={() => setShowAllDays((v) => !v)}
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2" className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
> >
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`} {showAllDays
? "Show less"
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}`}
</button> </button>
)} )}
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
</p> </p>
<ul className="mt-2 space-y-1"> <ul className="mt-2 space-y-1">
{result.vacationOverlap.map((v, i) => ( {result.vacationOverlap.map((v, i) => (
<li 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="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>} {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> </li>
))} ))}
</ul> </ul>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef, useState, useMemo } from "react"; import { useRef, useState, useMemo } from "react";
import { AllocationStatus } from "@capakraken/shared"; import { AllocationStatus } from "@nexus/shared";
import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { formatCents, formatDateMedium } from "~/lib/format.js"; import { formatCents, formatDateMedium } from "~/lib/format.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const { data: resources } = trpc.resource.listStaff.useQuery( const { data: resources } = trpc.resource.listStaff.useQuery(
{ isActive: true, search: debouncedSearch || undefined, limit: 50 }, { isActive: true, search: debouncedSearch || undefined, limit: 50 },
{ staleTime: 15_000 }, { staleTime: 15_000 },
) as { 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( const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
{ {
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const lcrCents = selectedResource.lcrCents ?? 0; const lcrCents = selectedResource.lcrCents ?? 0;
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours); const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
setPlanned((prev) => [...prev, { setPlanned((prev) => [
resourceId: selectedResource.id, ...prev,
resourceName: selectedResource.displayName, {
eid: selectedResource.eid, resourceId: selectedResource.id,
hoursPerDay, resourceName: selectedResource.displayName,
availableHours: avail.totalAvailableHours, eid: selectedResource.eid,
availableDays: avail.availableDays, hoursPerDay,
conflictDays: avail.conflictDays, availableHours: avail.totalAvailableHours,
coveragePercent: avail.coveragePercent, availableDays: avail.availableDays,
estimatedCostCents, conflictDays: avail.conflictDays,
}]); coveragePercent: avail.coveragePercent,
estimatedCostCents,
},
]);
// Reset for next resource // Reset for next resource
setResourceId(""); setResourceId("");
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
status: AllocationStatus.PROPOSED, status: AllocationStatus.PROPOSED,
}); });
} catch (err) { } 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); setSubmitting(false);
return; return;
} }
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
return ( return (
<div <div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }} onClick={(e) => {
if (e.target === e.currentTarget && !submitting) onClose();
}}
> >
<div <div
ref={panelRef} ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4" className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }} onKeyDown={(e) => {
if (e.key === "Escape" && !submitting) onClose();
}}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"} {phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." /> <InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
</h2> </h2>
<button 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>
<div className="px-6 pt-4 pb-2 space-y-3"> <div className="px-6 pt-4 pb-2 space-y-3">
{/* Demand summary */} {/* Demand summary */}
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3"> <div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
<div 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="flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div> <div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} {formatDateMedium(allocation.endDate)} {allocation.project?.name} · {formatDateMedium(allocation.startDate)} {" "}
{formatDateMedium(allocation.endDate)}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total {allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""} {allocation.budgetCents && allocation.budgetCents > 0
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
: ""}
</div> </div>
</div> </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="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5"> <div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
<span>Demand coverage</span> <span>Demand coverage</span>
<span>{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>
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex"> <div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
{planned.map((r, i) => ( {planned.map((r, i) => (
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{planned.map((r, i) => ( {planned.map((r, i) => (
<div key={r.resourceId} className="flex items-center gap-2 text-xs group"> <div key={r.resourceId} className="flex items-center gap-2 text-xs group">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} /> <div
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span> 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-400">({r.eid})</span>
<span className="text-gray-500">{r.hoursPerDay}h/day</span> <span className="text-gray-500">{r.hoursPerDay}h/day</span>
<span className="ml-auto text-gray-500">{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" && ( {phase === "plan" && (
<button <button
type="button" type="button"
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && ( {remainingHours > 0 && (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" /> <div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
<span className="text-amber-600 dark:text-amber-400 font-medium">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>
)} )}
</div> </div>
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{phase === "plan" && ( {phase === "plan" && (
<div className="px-6 pb-5 space-y-4"> <div className="px-6 pb-5 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Search Resource
</label>
<input <input
type="text" type="text"
placeholder="Search by name or EID..." placeholder="Search by name or EID..."
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Select Resource
</label>
<select <select
value={resourceId} value={resourceId}
onChange={(e) => setResourceId(e.target.value)} onChange={(e) => setResourceId(e.target.value)}
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Hours / Day
</label>
<input <input
type="number" type="number"
value={hoursPerDay} value={hoursPerDay}
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{/* Availability preview */} {/* Availability preview */}
{resourceId && avail && ( {resourceId && avail && (
<div className={`rounded-lg p-3 border text-sm ${ <div
avail.coveragePercent >= 100 className={`rounded-lg p-3 border text-sm ${
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800" avail.coveragePercent >= 100
: avail.coveragePercent >= 50 ? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800" : avail.coveragePercent >= 50
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" ? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
}`}> : "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
}`}
>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5"> <div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
Availability: {avail.resource.name} Availability: {avail.resource.name}
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs"> <div className="grid grid-cols-3 gap-2 text-xs">
<div> <div>
<span className="text-gray-500 dark:text-gray-400">Available</span> <span className="text-gray-500 dark:text-gray-400">Available</span>
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div> <div className="font-semibold text-green-700 dark:text-green-400">
{avail.availableDays} days
</div>
</div> </div>
<div> <div>
<span className="text-gray-500 dark:text-gray-400">Conflicts</span> <span className="text-gray-500 dark:text-gray-400">Conflicts</span>
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div> <div className="font-semibold text-red-700 dark:text-red-400">
{avail.conflictDays} days
</div>
</div> </div>
<div> <div>
<span className="text-gray-500 dark:text-gray-400">Hours</span> <span className="text-gray-500 dark:text-gray-400">Hours</span>
<div className="font-semibold text-gray-900 dark:text-gray-100">{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>
</div> </div>
{avail.existingAssignments.length > 0 && ( {avail.existingAssignments.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700"> <div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">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) => ( {avail.existingAssignments.slice(0, 4).map((a, i) => (
<div key={i} className="text-xs text-gray-600 dark:text-gray-300"> <div key={i} className="text-xs text-gray-600 dark:text-gray-300">
{a.code} · {a.hoursPerDay}h/day · {a.start} {a.end} {a.code} · {a.hoursPerDay}h/day · {a.start} {a.end}
</div> </div>
))} ))}
{avail.existingAssignments.length > 4 && ( {avail.existingAssignments.length > 4 && (
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div> <div className="text-xs text-gray-400">
+{avail.existingAssignments.length - 4} more
</div>
)} )}
</div> </div>
)} )}
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
)} )}
{resourceId && availabilityQuery.isLoading && ( {resourceId && availabilityQuery.isLoading && (
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div> <div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
Checking availability...
</div>
)} )}
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center justify-between gap-3 pt-2"> <div className="flex items-center justify-between gap-3 pt-2">
<button 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 Cancel
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50 dark:bg-gray-900">
<tr> <tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th> Resource
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th> </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">
<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> 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> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800"> <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{r.resourceName} {r.resourceName}
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span> <span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
</td> </td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td> <td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td> {r.hoursPerDay}h
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td> </td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{Math.round(r.availableHours)}h
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
{formatCents(r.estimatedCostCents)} EUR
</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<span 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}% {r.coveragePercent}%
</span> </span>
</td> </td>
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
</tbody> </tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900"> <tfoot className="bg-gray-50 dark:bg-gray-900">
<tr> <tr>
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">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" />
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300"> <td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{Math.round(consumedHours)}h / {totalDemandHours}h {Math.round(consumedHours)}h / {totalDemandHours}h
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR {formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
</td> </td>
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300"> <td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}% {totalDemandHours > 0
? Math.round((consumedHours / totalDemandHours) * 100)
: 0}
%
</td> </td>
</tr> </tr>
{allocation.budgetCents && allocation.budgetCents > 0 && ( {allocation.budgetCents && allocation.budgetCents > 0 && (
<tr> <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"> <td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
{formatCents(allocation.budgetCents)} EUR {formatCents(allocation.budgetCents)} EUR
</td> </td>
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0); const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
const remain = allocation.budgetCents! - totalCost; const remain = allocation.budgetCents! - totalCost;
return ( return (
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}> <span
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`} className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
>
{remain < 0
? `${formatCents(Math.abs(remain))} over`
: `${formatCents(remain)} left`}
</span> </span>
); );
})()} })()}
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{remainingHours > 0 && ( {remainingHours > 0 && (
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800"> <div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially. {Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
partially.
</div> </div>
)} )}
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
disabled={submitting || planned.length === 0} disabled={submitting || planned.length === 0}
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50" className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
> >
{submitting ? `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> </button>
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import type { AllocationWithDetails } from "@capakraken/shared"; import type { AllocationWithDetails } from "@nexus/shared";
type DemandRow = AllocationWithDetails & { type DemandRow = AllocationWithDetails & {
sourceAllocationId?: string; sourceAllocationId?: string;
@@ -1,7 +1,7 @@
"use client"; "use client";
import { RecurrenceFrequency } from "@capakraken/shared"; import { RecurrenceFrequency } from "@nexus/shared";
import type { RecurrencePattern } from "@capakraken/shared"; import type { RecurrencePattern } from "@nexus/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -39,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"> <div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Frequency selector */} {/* Frequency selector */}
<div> <div>
<span className={labelClass}>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"> <div className="flex gap-2 flex-wrap">
{Object.values(RecurrenceFrequency).map((f) => ( {Object.values(RecurrenceFrequency).map((f) => (
<button <button
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{f === RecurrenceFrequency.WEEKLY {f === RecurrenceFrequency.WEEKLY
? "Weekly" ? "Weekly"
: f === RecurrenceFrequency.BIWEEKLY : f === RecurrenceFrequency.BIWEEKLY
? "Biweekly" ? "Biweekly"
: f === RecurrenceFrequency.MONTHLY : f === RecurrenceFrequency.MONTHLY
? "Monthly" ? "Monthly"
: "Custom"} : "Custom"}
</button> </button>
))} ))}
</div> </div>
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Weekday picker — WEEKLY and BIWEEKLY */} {/* Weekday picker — WEEKLY and BIWEEKLY */}
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && ( {(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
<div> <div>
<span className={labelClass}>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"> <div className="flex gap-1">
{WEEKDAY_LABELS.map((label, dow) => { {WEEKDAY_LABELS.map((label, dow) => {
const selected = (value?.weekdays ?? []).includes(dow); const selected = (value?.weekdays ?? []).includes(dow);
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */} {/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
{freq !== RecurrenceFrequency.CUSTOM && ( {freq !== RecurrenceFrequency.CUSTOM && (
<div> <div>
<label className={labelClass}>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 <input
type="number" type="number"
min={0.5} min={0.5}
@@ -71,8 +71,8 @@ interface AssistantInsight {
sections?: AssistantInsightSection[]; sections?: AssistantInsightSection[];
} }
const STORAGE_KEY = "capakraken-chat-messages"; const STORAGE_KEY = "nexus-chat-messages";
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id"; const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
function isAssistantApproval(value: unknown): value is AssistantApproval { function isAssistantApproval(value: unknown): value is AssistantApproval {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { FieldType } from "@capakraken/shared"; import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared"; import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js"; import { RolePresetsEditor } from "./RolePresetsEditor.js";
import { FieldCard } from "./FieldCard.js"; import { FieldCard } from "./FieldCard.js";
@@ -48,10 +48,7 @@ interface FieldState {
// Helpers: Convert between FieldState and BlueprintFieldDefinition // Helpers: Convert between FieldState and BlueprintFieldDefinition
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function fieldDefToState( function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
def: BlueprintFieldDefinition,
target: BlueprintTargetValue,
): FieldState {
const catalogField = findCatalogField(target, def.key); const catalogField = findCatalogField(target, def.key);
if (catalogField) { if (catalogField) {
return { return {
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
// Build initial state from existing fieldDefs + catalog // Build initial state from existing fieldDefs + catalog
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const [catalogOverrides, setCatalogOverrides] = useState< const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
Record<string, FieldOverrides>
>(() => {
const map: Record<string, FieldOverrides> = {}; const map: Record<string, FieldOverrides> = {};
// Start with all catalog fields disabled // Start with all catalog fields disabled
for (const cf of catalog) { for (const cf of catalog) {
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
// Handlers // Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const handleCatalogFieldChange = useCallback( const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
(key: string, overrides: FieldOverrides) => { setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides })); }, []);
},
[],
);
const handleCustomFieldChange = useCallback( const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
(idx: number, overrides: FieldOverrides) => { setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
setCustomFields((prev) => }, []);
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
);
},
[],
);
function removeCustomField(idx: number) { function removeCustomField(idx: number) {
setCustomFields((prev) => prev.filter((_, i) => i !== idx)); setCustomFields((prev) => prev.filter((_, i) => i !== idx));
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
// Collapsed categories // Collapsed categories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>( const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
new Set(),
);
function toggleCategory(name: string) { function toggleCategory(name: string) {
setCollapsedCategories((prev) => { setCollapsedCategories((prev) => {
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
{/* Field cards */} {/* Field cards */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6"> <div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{categories {categories
.filter( .filter((cat) => activeCategory === null || activeCategory === cat.name)
(cat) =>
activeCategory === null ||
activeCategory === cat.name,
)
.map((cat) => { .map((cat) => {
const fields = fieldsByCategory.get(cat.name) ?? []; const fields = fieldsByCategory.get(cat.name) ?? [];
if (fields.length === 0 && searchQuery.trim()) return null; if (fields.length === 0 && searchQuery.trim()) return null;
if (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); 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"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
{cat.name} {cat.name}
</h3> </h3>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">{cat.description}</span>
{cat.description}
</span>
</button> </button>
{!isCollapsed && ( {!isCollapsed && (
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
key={field.key} key={field.key}
field={field} field={field}
overrides={catalogOverrides[field.key]!} overrides={catalogOverrides[field.key]!}
onChange={(ov) => onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
handleCatalogFieldChange(field.key, ov)
}
/> />
))} ))}
{fields.length === 0 && ( {fields.length === 0 && (
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
})} })}
{/* Custom Fields section */} {/* Custom Fields section */}
{(activeCategory === null || {(activeCategory === null || activeCategory === "Custom Fields") && (
activeCategory === "Custom Fields") && (
<div> <div>
<button <button
type="button" type="button"
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
className="flex items-center gap-2 mb-3 w-full text-left group" className="flex items-center gap-2 mb-3 w-full text-left group"
> >
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600"> <span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
{collapsedCategories.has("Custom Fields") {collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
? "\u25B6"
: "\u25BC"}
</span> </span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Custom Fields Custom Fields
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
label: cf.custom.label, label: cf.custom.label,
type: cf.custom.type, type: cf.custom.type,
category: "Custom Fields", category: "Custom Fields",
description: description: cf.overrides.description || "Custom field",
cf.overrides.description || "Custom field",
...(cf.custom.options.length > 0 ...(cf.custom.options.length > 0
? { options: cf.custom.options } ? { options: cf.custom.options }
: {}), : {}),
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
<FieldCard <FieldCard
field={pseudoCatalog} field={pseudoCatalog}
overrides={cf.overrides} overrides={cf.overrides}
onChange={(ov) => onChange={(ov) => handleCustomFieldChange(idx, ov)}
handleCustomFieldChange(idx, ov)
}
/> />
<button <button
type="button" type="button"
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600"> <label className="text-xs font-medium text-gray-600">
Key{" "} Key <span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={customKey} value={customKey}
onChange={(e) => onChange={(e) =>
setCustomKey( setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
e.target.value.replace(
/[^a-zA-Z0-9_]/g,
"",
),
)
} }
placeholder="field_key" placeholder="field_key"
className="app-input font-mono" className="app-input font-mono"
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600"> <label className="text-xs font-medium text-gray-600">
Label{" "} Label <span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
value={customLabel} value={customLabel}
onChange={(e) => onChange={(e) => setCustomLabel(e.target.value)}
setCustomLabel(e.target.value)
}
placeholder="Display Label" placeholder="Display Label"
className="app-input" className="app-input"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs font-medium text-gray-600"> <label className="text-xs font-medium text-gray-600">Type</label>
Type
</label>
<select <select
value={customType} value={customType}
onChange={(e) => onChange={(e) => setCustomType(e.target.value as FieldType)}
setCustomType(
e.target.value as FieldType,
)
}
className="app-input" className="app-input"
> >
{FIELD_TYPES.map((ft) => ( {FIELD_TYPES.map((ft) => (
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
<button <button
type="button" type="button"
onClick={addCustomField} onClick={addCustomField}
disabled={ disabled={!customKey.trim() || !customLabel.trim()}
!customKey.trim() || !customLabel.trim()
}
className={BTN_PRIMARY} className={BTN_PRIMARY}
> >
Add Add
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
onClick={() => setShowCustomForm(true)} onClick={() => setShowCustomForm(true)}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2" className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
> >
<span className="text-lg leading-none">+</span>{" "} <span className="text-lg leading-none">+</span> Add Custom Field
Add Custom Field
</button> </button>
)} )}
</div> </div>
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0"> <div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be {enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
saved
</span> </span>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" onClick={onClose} className={BTN_SECONDARY}> <button type="button" onClick={onClose} className={BTN_SECONDARY}>
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
) : ( ) : (
<div className="px-6 py-4 overflow-y-auto"> <div className="px-6 py-4 overflow-y-auto">
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
Wizard when this blueprint is selected. blueprint is selected.
</p> </p>
<RolePresetsEditor <RolePresetsEditor
initialPresets={initialRolePresets} initialPresets={initialRolePresets}
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { FieldType } from "@capakraken/shared"; import { FieldType } from "@nexus/shared";
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared"; import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { RolePresetsEditor } from "./RolePresetsEditor.js"; import { RolePresetsEditor } from "./RolePresetsEditor.js";
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
} }
function updateOption(idx: number, field: "value" | "label", val: string) { function updateOption(idx: number, field: "value" | "label", val: string) {
const next = options.map((o, i) => const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
i === idx ? { ...o, [field]: val } : o,
);
onChange(next); onChange(next);
} }
@@ -111,8 +109,7 @@ interface FieldRowProps {
function FieldRow({ field, onChange, onDelete }: FieldRowProps) { function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const needsOptions = const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
function update<K extends keyof BlueprintFieldDefinition>( function update<K extends keyof BlueprintFieldDefinition>(
key: K, key: K,
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
{/* Main row */} {/* Main row */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Drag handle placeholder */} {/* Drag handle placeholder */}
<span className="text-gray-300 cursor-grab select-none text-lg leading-none"> <span className="text-gray-300 cursor-grab select-none text-lg leading-none"></span>
</span>
{/* Key */} {/* Key */}
<input <input
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
// Clear options when switching away from select types // Clear options when switching away from select types
const clearedOptions = const clearedOptions =
t === FieldType.SELECT || t === FieldType.MULTI_SELECT t === FieldType.SELECT || t === FieldType.MULTI_SELECT
? field.options ?? [] ? (field.options ?? [])
: undefined; : undefined;
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition); onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
}} }}
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium"> <label className="text-xs text-gray-500 font-medium">Placeholder</label>
Placeholder
</label>
<input <input
type="text" type="text"
value={field.placeholder ?? ""} value={field.placeholder ?? ""}
onChange={(e) => onChange={(e) => update("placeholder", e.target.value || undefined)}
update("placeholder", e.target.value || undefined)
}
placeholder="Placeholder text" placeholder="Placeholder text"
className="app-input" className="app-input"
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500 font-medium"> <label className="text-xs text-gray-500 font-medium">Description</label>
Description
</label>
<input <input
type="text" type="text"
value={field.description ?? ""} value={field.description ?? ""}
onChange={(e) => onChange={(e) => update("description", e.target.value || undefined)}
update("description", e.target.value || undefined)
}
placeholder="Helper text" placeholder="Helper text"
className="app-input" className="app-input"
/> />
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab); const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
const [fields, setFields] = useState<BlueprintFieldDefinition[]>( const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
() => [...initialFieldDefs].sort((a, b) => a.order - b.order),
[...initialFieldDefs].sort((a, b) => a.order - b.order),
); );
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [presetSaveError, setPresetSaveError] = useState<string | null>(null); const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
} }
function removeField(idx: number) { function removeField(idx: number) {
setFields((prev) => setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
prev
.filter((_, i) => i !== idx)
.map((f, i) => ({ ...f, order: i })),
);
} }
function updateField(idx: number, updated: BlueprintFieldDefinition) { function updateField(idx: number, updated: BlueprintFieldDefinition) {
setFields((prev) => setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
prev.map((f, i) => (i === idx ? updated : f)),
);
} }
function handleSave() { function handleSave() {
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Edit Fields:{" "} Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
<span className="text-gray-600 font-normal">{blueprintName}</span>
</h2> </h2>
<button <button
type="button" type="button"
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
) : ( ) : (
<div className="px-6 py-4"> <div className="px-6 py-4">
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected. Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
blueprint is selected.
</p> </p>
<RolePresetsEditor <RolePresetsEditor
initialPresets={initialRolePresets} initialPresets={initialRolePresets}
@@ -2,8 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import type { BlueprintTarget } from "@capakraken/shared"; import type { BlueprintTarget } from "@nexus/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared"; import type { BlueprintFieldDefinition } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js"; import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
import { useSelection } from "~/hooks/useSelection.js"; import { useSelection } from "~/hooks/useSelection.js";
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
} }
initialRolePresets={ initialRolePresets={
Array.isArray(editingBlueprint.rolePresets) Array.isArray(editingBlueprint.rolePresets)
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[]) ? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
: [] : []
} }
initialTab={editingTab} initialTab={editingTab}

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