2 Commits

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

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

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

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