Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12044f638e | |||
| 2383bcbdc0 | |||
| 0e9d6ec388 | |||
| 7285668c52 | |||
| 944d36bdb2 | |||
| 6ec512e302 | |||
| 4a841d5acb | |||
| 749a39097c | |||
| a58b99a33a | |||
| c5b58a5bdc | |||
| 52ddbe7377 | |||
| 19aeb2ba04 | |||
| b41c1d2501 | |||
| d9a7ec0338 | |||
| 17471af7f8 | |||
| f0251a654a | |||
| fe79810a85 | |||
| 9dc1ffd3ad | |||
| 656c9329f7 |
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"aed37e34-4be8-4788-b03a-7145d9b4b2ce","pid":3544538,"procStart":"34480817","acquiredAt":1779373227101}
|
||||||
+7
-7
@@ -1,5 +1,5 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# CapaKraken — environment variable reference
|
# Nexus — environment variable reference
|
||||||
#
|
#
|
||||||
# Copy this file to .env and fill in the values before running the app.
|
# Copy this file to .env and fill in the values before running the app.
|
||||||
# Lines starting with # are comments. Lines with no value are optional.
|
# Lines starting with # are comments. Lines with no value are optional.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
# REQUIRED — Public URL of the app (with scheme, no trailing slash).
|
# REQUIRED — Public URL of the app (with scheme, no trailing slash).
|
||||||
# Used in email links (invites, password reset) and as the Auth.js base URL.
|
# Used in email links (invites, password reset) and as the Auth.js base URL.
|
||||||
# Must use https:// in production.
|
# Must use https:// in production.
|
||||||
NEXTAUTH_URL=https://capakraken.example.com
|
NEXTAUTH_URL=https://nexus.example.com
|
||||||
|
|
||||||
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
|
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
|
||||||
# Generate one with: openssl rand -base64 32
|
# Generate one with: openssl rand -base64 32
|
||||||
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
|
|||||||
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
|
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
|
||||||
# container this variable is overridden by docker-compose.yml (which routes
|
# container this variable is overridden by docker-compose.yml (which routes
|
||||||
# to the postgres service name on the internal network).
|
# to the postgres service name on the internal network).
|
||||||
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus
|
||||||
|
|
||||||
# ─── Redis ───────────────────────────────────────────────────────────────────
|
# ─── Redis ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ REDIS_PASSWORD=
|
|||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
# SMTP_USER=no-reply@example.com
|
# SMTP_USER=no-reply@example.com
|
||||||
# SMTP_PASSWORD=
|
# SMTP_PASSWORD=
|
||||||
# SMTP_FROM=CapaKraken <no-reply@example.com>
|
# SMTP_FROM=Nexus <no-reply@example.com>
|
||||||
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
|
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
|
||||||
|
|
||||||
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
|
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
|
||||||
@@ -74,8 +74,8 @@ REDIS_PASSWORD=
|
|||||||
# Used as the password for the pgAdmin web UI (http://localhost:5050).
|
# Used as the password for the pgAdmin web UI (http://localhost:5050).
|
||||||
PGADMIN_PASSWORD=
|
PGADMIN_PASSWORD=
|
||||||
|
|
||||||
# Email shown on the pgAdmin login screen (default: admin@capakraken.dev).
|
# Email shown on the pgAdmin login screen (default: admin@nexus.dev).
|
||||||
# PGADMIN_EMAIL=admin@capakraken.dev
|
# PGADMIN_EMAIL=admin@nexus.dev
|
||||||
|
|
||||||
# ─── Logging ─────────────────────────────────────────────────────────────────
|
# ─── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
|
|||||||
# that any resolved path remains inside this directory; this prevents an
|
# that any resolved path remains inside this directory; this prevents an
|
||||||
# admin (or compromised admin token) from pointing the parser at arbitrary
|
# admin (or compromised admin token) from pointing the parser at arbitrary
|
||||||
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
|
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
|
||||||
# DISPO_IMPORT_DIR=/var/lib/capakraken/imports
|
# DISPO_IMPORT_DIR=/var/lib/nexus/imports
|
||||||
|
|
||||||
# ─── Testing (never enable in production) ────────────────────────────────────
|
# ─── Testing (never enable in production) ────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ Absolute Pfade unter `/share/Container/gitea/` sind **außerhalb** der Container
|
|||||||
|
|
||||||
## Repo-Secrets für CI/CD
|
## Repo-Secrets für CI/CD
|
||||||
|
|
||||||
Im capakraken-Repo → **Settings → Actions → Secrets** eintragen:
|
Im nexus-Repo → **Settings → Actions → Secrets** eintragen:
|
||||||
|
|
||||||
| Secret | Zweck |
|
| Secret | Zweck |
|
||||||
| ----------------------- | -------------------------------------- |
|
| ----------------------- | -------------------------------------- |
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you discover a security vulnerability in CapaKraken, please report it responsibly.
|
If you discover a security vulnerability in Nexus, please report it responsibly.
|
||||||
|
|
||||||
**Do not** open a public GitHub issue for security vulnerabilities.
|
**Do not** open a public GitHub issue for security vulnerabilities.
|
||||||
|
|
||||||
|
|||||||
+44
-40
@@ -1,6 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Retrigger marker: b2d89ca (docker-deploy smoke retry)
|
# Retrigger marker: fe79810 (Build log lost — retrigger to re-observe)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
run: pnpm db:generate
|
run: pnpm db:generate
|
||||||
|
|
||||||
- name: Run assistant split regression
|
- name: Run assistant split regression
|
||||||
run: pnpm --filter @capakraken/api test:assistant-split
|
run: pnpm --filter @nexus/api test:assistant-split
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Lint — ~20s, no services needed
|
# Lint — ~20s, no services needed
|
||||||
@@ -159,11 +159,11 @@ jobs:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: nexus_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: capakraken_test
|
POSTGRES_PASSWORD: nexus_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
@@ -175,7 +175,7 @@ jobs:
|
|||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
|
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
|
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
|
||||||
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
|
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
|
||||||
@@ -204,13 +204,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests with coverage
|
- name: Run unit tests with coverage
|
||||||
run: |
|
run: |
|
||||||
pnpm --filter @capakraken/web test:unit -- --coverage
|
pnpm --filter @nexus/web test:unit -- --coverage
|
||||||
pnpm --filter @capakraken/engine exec vitest run --coverage
|
pnpm --filter @nexus/engine exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/staffing exec vitest run --coverage
|
pnpm --filter @nexus/staffing exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/api exec vitest run --coverage
|
pnpm --filter @nexus/api exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/application exec vitest run --coverage
|
pnpm --filter @nexus/application exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/shared exec vitest run --coverage
|
pnpm --filter @nexus/shared exec vitest run --coverage
|
||||||
pnpm --filter @capakraken/db test:unit
|
pnpm --filter @nexus/db test:unit
|
||||||
|
|
||||||
- name: Upload coverage reports
|
- name: Upload coverage reports
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -274,7 +274,7 @@ jobs:
|
|||||||
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm --filter @capakraken/web exec next build
|
run: pnpm --filter @nexus/web exec next build
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# E2E — depends on build, needs PostgreSQL + Redis
|
# E2E — depends on build, needs PostgreSQL + Redis
|
||||||
@@ -291,11 +291,11 @@ jobs:
|
|||||||
e2epg:
|
e2epg:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: nexus_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: capakraken_test
|
POSTGRES_PASSWORD: nexus_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
@@ -307,14 +307,14 @@ jobs:
|
|||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||||
# Playwright test-server.mjs requires an explicit test DB URL.
|
# Playwright test-server.mjs requires an explicit test DB URL.
|
||||||
PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||||
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
|
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
|
||||||
# the expected target; default is "capakraken", CI uses capakraken_test.
|
# the expected target; default is "nexus", CI uses nexus_test.
|
||||||
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
|
NEXUS_EXPECTED_DB_NAME: nexus_test
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
|
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
|
||||||
REDIS_URL: redis://e2eredis:6379
|
REDIS_URL: redis://e2eredis:6379
|
||||||
PORT: 3100
|
PORT: 3100
|
||||||
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
|
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
|
||||||
@@ -364,18 +364,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: pnpm --filter @capakraken/web exec playwright install --with-deps chromium
|
run: pnpm --filter @nexus/web exec playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Install Playwright system deps
|
- name: Install Playwright system deps
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
run: pnpm --filter @capakraken/web exec playwright install-deps chromium
|
run: pnpm --filter @nexus/web exec playwright install-deps chromium
|
||||||
|
|
||||||
- name: Install psql (debug schema state)
|
- name: Install psql (debug schema state)
|
||||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
|
||||||
|
|
||||||
- name: Push DB schema & seed
|
- name: Push DB schema & seed
|
||||||
env:
|
env:
|
||||||
PGPASSWORD: capakraken_test
|
PGPASSWORD: nexus_test
|
||||||
run: |
|
run: |
|
||||||
# Nuke any leftover schema state from a previous job that shared the
|
# Nuke any leftover schema state from a previous job that shared the
|
||||||
# postgres service container (act_runner reuses service volumes).
|
# postgres service container (act_runner reuses service volumes).
|
||||||
@@ -397,7 +397,7 @@ jobs:
|
|||||||
IPS=$(getent hosts e2epg | awk '{print $1}')
|
IPS=$(getent hosts e2epg | awk '{print $1}')
|
||||||
PG_IP=""
|
PG_IP=""
|
||||||
for ip in $IPS; do
|
for ip in $IPS; do
|
||||||
if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
||||||
PG_IP="$ip"
|
PG_IP="$ip"
|
||||||
echo "Locked onto postgres at $PG_IP"
|
echo "Locked onto postgres at $PG_IP"
|
||||||
break
|
break
|
||||||
@@ -406,19 +406,19 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$PG_IP" ]; then
|
if [ -z "$PG_IP" ]; then
|
||||||
echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
|
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
|
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test"
|
||||||
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||||
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||||
echo "--- DROP SCHEMA ---"
|
echo "--- DROP SCHEMA ---"
|
||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
|
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \
|
||||||
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
|
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;"
|
||||||
echo "--- prisma db push ---"
|
echo "--- prisma db push ---"
|
||||||
DATABASE_URL="$PINNED_URL" pnpm --filter @capakraken/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
||||||
echo "--- tables in public after push ---"
|
echo "--- tables in public after push ---"
|
||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
|
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -At \
|
||||||
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
||||||
| tee /tmp/tables.txt
|
| tee /tmp/tables.txt
|
||||||
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
|
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
|
||||||
@@ -438,7 +438,7 @@ jobs:
|
|||||||
# and restarts mid-run, producing cascading ECONNREFUSED failures
|
# and restarts mid-run, producing cascading ECONNREFUSED failures
|
||||||
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
|
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
|
||||||
# is run locally / in a dedicated nightly job.
|
# is run locally / in a dedicated nightly job.
|
||||||
run: pnpm --filter @capakraken/web exec playwright test e2e/smoke.spec.ts
|
run: pnpm --filter @nexus/web exec playwright test e2e/smoke.spec.ts
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -468,8 +468,8 @@ jobs:
|
|||||||
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
||||||
PGADMIN_PASSWORD=ci-pgadmin
|
PGADMIN_PASSWORD=ci-pgadmin
|
||||||
# Must match the password baked into docker-compose.ci.yml's
|
# Must match the password baked into docker-compose.ci.yml's
|
||||||
# DATABASE_URL override (capakraken_dev).
|
# DATABASE_URL override (nexus_dev).
|
||||||
POSTGRES_PASSWORD=capakraken_dev
|
POSTGRES_PASSWORD=nexus_dev
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Tear down any stale stack & volumes
|
- name: Tear down any stale stack & volumes
|
||||||
@@ -477,7 +477,11 @@ jobs:
|
|||||||
# runs. A previous run's failed migration entry in _prisma_migrations
|
# runs. A previous run's failed migration entry in _prisma_migrations
|
||||||
# causes P3009 on the next migrate deploy; wipe volumes for a truly
|
# causes P3009 on the next migrate deploy; wipe volumes for a truly
|
||||||
# fresh deploy test every time.
|
# fresh deploy test every time.
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
# Also tear down the legacy "capakraken" project (pre-Phase-3 rename)
|
||||||
|
# in case old containers are still holding host ports 5433/6380.
|
||||||
|
run: |
|
||||||
|
docker compose -p capakraken --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
||||||
|
docker compose --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
||||||
|
|
||||||
- name: Start infrastructure (postgres + redis)
|
- name: Start infrastructure (postgres + redis)
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
|
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
|
||||||
@@ -485,7 +489,7 @@ jobs:
|
|||||||
- name: Wait for postgres
|
- name: Wait for postgres
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
|
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -576,7 +580,7 @@ jobs:
|
|||||||
ln -sfn /app/packages/db/node_modules/@prisma /app/scripts/node_modules/@prisma
|
ln -sfn /app/packages/db/node_modules/@prisma /app/scripts/node_modules/@prisma
|
||||||
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
|
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
|
||||||
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
|
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
|
||||||
node /app/scripts/setup-admin.mjs --email admin@capakraken.dev --name Admin --password admin123
|
node /app/scripts/setup-admin.mjs --email admin@nexus.dev --name Admin --password admin123
|
||||||
'
|
'
|
||||||
|
|
||||||
- name: Set up Node.js 20
|
- name: Set up Node.js 20
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# CapaKraken
|
# Nexus
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
Nexus ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-P
|
|||||||
## Monorepo-Struktur
|
## Monorepo-Struktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
capakraken/
|
nexus/
|
||||||
├── apps/web
|
├── apps/web
|
||||||
├── packages/shared
|
├── packages/shared
|
||||||
├── packages/db
|
├── packages/db
|
||||||
@@ -41,7 +41,7 @@ capakraken/
|
|||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
- `pnpm test:unit`
|
- `pnpm test:unit`
|
||||||
- `pnpm --filter @capakraken/web exec tsc --noEmit`
|
- `pnpm --filter @nexus/web exec tsc --noEmit`
|
||||||
- `pnpm lint`
|
- `pnpm lint`
|
||||||
|
|
||||||
## Dokumente
|
## Dokumente
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @capakraken/db db:generate
|
RUN pnpm --filter @nexus/db db:generate
|
||||||
|
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -39,7 +39,7 @@ COPY --from=deps /app/ ./
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @capakraken/db db:generate
|
RUN pnpm --filter @nexus/db db:generate
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -63,7 +63,7 @@ RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
|
|||||||
AUTH_SECRET="$AUTH_SECRET" \
|
AUTH_SECRET="$AUTH_SECRET" \
|
||||||
DATABASE_URL="$DATABASE_URL" \
|
DATABASE_URL="$DATABASE_URL" \
|
||||||
REDIS_URL="$REDIS_URL" \
|
REDIS_URL="$REDIS_URL" \
|
||||||
pnpm --filter @capakraken/web build
|
pnpm --filter @nexus/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 3: Migration runner
|
# Stage 3: Migration runner
|
||||||
@@ -72,7 +72,7 @@ FROM builder AS migrator
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"]
|
CMD ["pnpm", "--filter", "@nexus/db", "db:migrate:deploy"]
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 4: Production runtime
|
# Stage 4: Production runtime
|
||||||
|
|||||||
+69
-18
@@ -1,6 +1,7 @@
|
|||||||
# CapaKraken – Projekt-Learnings
|
# Nexus – Projekt-Learnings
|
||||||
|
|
||||||
## Format
|
## Format
|
||||||
|
|
||||||
**Datum | Kategorie | Problem → Lösung**
|
**Datum | Kategorie | Problem → Lösung**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -12,11 +13,13 @@
|
|||||||
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
|
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
|
||||||
|
|
||||||
**Solution — split config pattern:**
|
**Solution — split config pattern:**
|
||||||
|
|
||||||
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
|
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
|
||||||
- `auth-edge.ts` — `NextAuth(authConfig)` with the lean config; used only by middleware
|
- `auth-edge.ts` — `NextAuth(authConfig)` with the lean config; used only by middleware
|
||||||
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
|
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
|
||||||
|
|
||||||
**Middleware wrapping:**
|
**Middleware wrapping:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { auth } from "./server/auth-edge.js";
|
import { auth } from "./server/auth-edge.js";
|
||||||
export default auth(function middleware(request) {
|
export default auth(function middleware(request) {
|
||||||
@@ -28,17 +31,19 @@ export default auth(function middleware(request) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Three-layer defence:**
|
**Three-layer defence:**
|
||||||
|
|
||||||
1. Middleware — server-side redirect before page renders
|
1. Middleware — server-side redirect before page renders
|
||||||
2. `SessionGuard` client component — `useSession()` → `router.replace()` on SPA navigation
|
2. `SessionGuard` client component — `useSession()` → `router.replace()` on SPA navigation
|
||||||
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
|
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
|
||||||
|
|
||||||
**Test mock pattern for middleware tests:**
|
**Test mock pattern for middleware tests:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
vi.mock("./server/auth-edge.js", () => ({
|
vi.mock("./server/auth-edge.js", () => ({
|
||||||
auth: (handler) => (req) =>
|
auth: (handler) => (req) => handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
||||||
handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
|
||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
|
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,12 +55,14 @@ Needed because `vi.resetModules()` inside the helper function doesn't re-apply t
|
|||||||
**Repo path:** `Hartmut/plANARCHY`
|
**Repo path:** `Hartmut/plANARCHY`
|
||||||
|
|
||||||
Usage example (list open issues):
|
Usage example (list open issues):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
|
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
|
||||||
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
|
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
|
||||||
```
|
```
|
||||||
|
|
||||||
Close an issue with a comment:
|
Close an issue with a comment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TOKEN=$(cat ~/.gitea-token)
|
TOKEN=$(cat ~/.gitea-token)
|
||||||
REPO="Hartmut/plANARCHY"
|
REPO="Hartmut/plANARCHY"
|
||||||
@@ -75,18 +82,22 @@ curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/
|
|||||||
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
|
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
|
||||||
|
|
||||||
**Solution:** Always restart the app container after Prisma schema changes:
|
**Solution:** Always restart the app container after Prisma schema changes:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker compose --profile full restart app
|
docker compose --profile full restart app
|
||||||
```
|
```
|
||||||
|
|
||||||
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
|
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
|
||||||
|
|
||||||
**Rule:** Prisma schema change checklist:
|
**Rule:** Prisma schema change checklist:
|
||||||
|
|
||||||
1. Edit `packages/db/prisma/schema.prisma`
|
1. Edit `packages/db/prisma/schema.prisma`
|
||||||
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
|
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
|
||||||
3. Apply migration to the running DB directly (for dev speed): `docker exec capakraken-postgres-1 psql -U capakraken -d capakraken < migration.sql`
|
3. Apply migration to the running DB directly (for dev speed): `docker exec nexus-postgres-1 psql -U nexus -d nexus < migration.sql`
|
||||||
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
|
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
|
||||||
|
|
||||||
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
|
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
|
||||||
|
|
||||||
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
|
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
|
||||||
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
|
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
|
||||||
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
|
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
|
||||||
@@ -95,24 +106,28 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
|
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
|
||||||
|
|
||||||
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
|
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
|
||||||
|
|
||||||
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
|
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
|
||||||
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
|
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
|
||||||
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
|
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
|
||||||
|
|
||||||
### 2026-03-12 | Build | NextAuth portable export typing
|
### 2026-03-12 | Build | NextAuth portable export typing
|
||||||
|
|
||||||
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
|
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
|
||||||
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
|
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
|
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
|
||||||
|
|
||||||
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
|
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
|
||||||
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
|
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
|
||||||
- `packages/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application`
|
- `packages/application` depends on `@nexus/db`, `@nexus/engine`, `@nexus/shared`; `packages/api` depends on `@nexus/application`
|
||||||
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
|
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
|
||||||
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
|
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
|
||||||
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
|
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
|
||||||
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
|
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
|
||||||
|
|
||||||
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
|
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
|
||||||
|
|
||||||
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
|
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
|
||||||
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
|
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
|
||||||
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
|
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
|
||||||
@@ -120,12 +135,14 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
|
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
|
||||||
|
|
||||||
### 2026-03-12 | Architecture | Estimating foundation slice
|
### 2026-03-12 | Architecture | Estimating foundation slice
|
||||||
|
|
||||||
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
|
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
|
||||||
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
|
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
|
||||||
- Shared estimate enums/types/schemas now live in `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`.
|
- Shared estimate enums/types/schemas now live in `@nexus/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@nexus/application`.
|
||||||
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
|
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
|
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
|
||||||
|
|
||||||
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
|
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
|
||||||
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
|
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
|
||||||
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
|
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
|
||||||
@@ -134,45 +151,55 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
|||||||
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
|
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
|
||||||
|
|
||||||
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
|
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
|
||||||
|
|
||||||
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
|
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
|
||||||
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
|
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
|
||||||
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
|
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
|
||||||
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
|
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
|
||||||
|
|
||||||
### 2026-03-11 | Security | Phase 0 critical fixes
|
### 2026-03-11 | Security | Phase 0 critical fixes
|
||||||
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution.
|
|
||||||
|
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@nexus/db` is not enough for TS resolution.
|
||||||
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
|
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
|
||||||
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
|
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
|
||||||
- `@capakraken/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
- `@nexus/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
||||||
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
|
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
|
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
|
||||||
|
|
||||||
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
|
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
|
||||||
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
|
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
|
||||||
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
|
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
|
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
|
||||||
|
|
||||||
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
|
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
|
||||||
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
|
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | recomputeValueScores sequential updates
|
### 2026-03-09 | Performance | recomputeValueScores sequential updates
|
||||||
|
|
||||||
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
|
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
|
||||||
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
|
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
|
||||||
|
|
||||||
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
|
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
|
||||||
|
|
||||||
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
|
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
|
||||||
**Fix:** Use `ctx.dbUser?.id` directly.
|
**Fix:** Use `ctx.dbUser?.id` directly.
|
||||||
|
|
||||||
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
|
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
|
||||||
|
|
||||||
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
|
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
|
||||||
|
|
||||||
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
|
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
|
||||||
|
|
||||||
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
|
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
|
||||||
|
|
||||||
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
|
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
|
||||||
|
|
||||||
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
|
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
|
||||||
|
|
||||||
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
|
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
|
||||||
|
|
||||||
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
|
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
|
||||||
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
|
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
|
||||||
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
|
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
|
||||||
@@ -180,6 +207,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
|
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
|
||||||
|
|
||||||
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
|
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
|
||||||
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
|
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
|
||||||
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin.
|
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin.
|
||||||
@@ -187,6 +215,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
|
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
|
||||||
|
|
||||||
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
|
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
|
||||||
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
|
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
|
||||||
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
|
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
|
||||||
@@ -194,19 +223,22 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
|
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
|
||||||
|
|
||||||
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
|
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
|
||||||
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
|
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
|
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
|
||||||
|
|
||||||
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
|
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
|
||||||
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben.
|
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/nexus`) verschieben.
|
||||||
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
|
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
|
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
|
||||||
|
|
||||||
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
|
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
|
||||||
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
|
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
|
||||||
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
|
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
|
||||||
@@ -214,6 +246,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
|
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
|
||||||
|
|
||||||
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
|
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
|
||||||
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
|
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
|
||||||
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
|
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
|
||||||
@@ -221,6 +254,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
|
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
|
||||||
|
|
||||||
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab.
|
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab.
|
||||||
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
|
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
|
||||||
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
|
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
|
||||||
@@ -228,8 +262,10 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
|
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
|
||||||
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind.
|
|
||||||
|
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@nexus/shared`-Enums kompatibel sind.
|
||||||
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
|
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
|
||||||
|
|
||||||
- `project as unknown as Project`
|
- `project as unknown as Project`
|
||||||
- `form.orderType as unknown as OrderType`
|
- `form.orderType as unknown as OrderType`
|
||||||
- `resource.skills as unknown as SkillEntry[]`
|
- `resource.skills as unknown as SkillEntry[]`
|
||||||
@@ -237,6 +273,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-02-xx | Architektur | SSE statt WebSockets
|
### 2026-02-xx | Architektur | SSE statt WebSockets
|
||||||
|
|
||||||
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
|
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
|
||||||
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
|
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
|
||||||
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
|
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
|
||||||
@@ -247,20 +284,21 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
|
|
||||||
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar.
|
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar.
|
||||||
**Lösung:** Schrittweise Extraktion:
|
**Lösung:** Schrittweise Extraktion:
|
||||||
|
|
||||||
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
|
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
|
||||||
2. Heatmap-Utilities → `heatmapUtils.ts`
|
2. Heatmap-Utilities → `heatmapUtils.ts`
|
||||||
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
|
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
|
||||||
4. Header-JSX → `TimelineHeader.tsx`
|
4. Header-JSX → `TimelineHeader.tsx`
|
||||||
5. Toolbar-JSX → `TimelineToolbar.tsx`
|
5. Toolbar-JSX → `TimelineToolbar.tsx`
|
||||||
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
||||||
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
|
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
|
||||||
|
|
||||||
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
|
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
|
||||||
**Lösung:** `ioredis` in `@capakraken/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `capakraken:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
**Lösung:** `ioredis` in `@nexus/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `nexus:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
||||||
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5.
|
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5.
|
||||||
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
|
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
|
||||||
|
|
||||||
@@ -340,10 +378,12 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
|||||||
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
|
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
|
||||||
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
|
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
|
||||||
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
|
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const adminHash = await hash("admin123");
|
const adminHash = await hash("admin123");
|
||||||
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
|
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
|
||||||
```
|
```
|
||||||
|
|
||||||
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
|
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -383,18 +423,20 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
|
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
|
||||||
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
|
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
|
||||||
**Lösung (zweistufig):**
|
**Lösung (zweistufig):**
|
||||||
|
|
||||||
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
|
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
|
||||||
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
|
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
|
||||||
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
||||||
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
|
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
|
||||||
|
|
||||||
**Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
**Kontext:** Nexus hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
||||||
|
|
||||||
**Lösung:** Zweigeteiltes System:
|
**Lösung:** Zweigeteiltes System:
|
||||||
|
|
||||||
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
|
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
|
||||||
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
|
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
|
||||||
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
|
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
|
||||||
@@ -421,6 +463,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Offene Fragen
|
## Offene Fragen
|
||||||
|
|
||||||
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
|
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
|
||||||
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
|
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
|
||||||
- [x] P7.2 Touch-Support → umgesetzt
|
- [x] P7.2 Touch-Support → umgesetzt
|
||||||
@@ -430,6 +473,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik
|
### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik
|
||||||
|
|
||||||
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
|
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
|
||||||
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
|
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
|
||||||
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
|
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
|
||||||
@@ -439,14 +483,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
|
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
|
||||||
|
|
||||||
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
|
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
|
||||||
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
|
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
|
||||||
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
|
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
|
||||||
**Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
|
**Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
|
||||||
|
|
||||||
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
|
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
|
||||||
|
|
||||||
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
|
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
|
|
||||||
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
|
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
|
||||||
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
|
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
|
||||||
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
|
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
|
||||||
@@ -457,15 +504,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
|
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
|
||||||
|
|
||||||
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
|
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
|
||||||
|
|
||||||
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
|
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
|
|
||||||
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
|
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
|
||||||
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
|
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
|
||||||
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
|
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
|
||||||
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
|
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
|
||||||
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
|
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
|
||||||
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
|
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
|
||||||
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
||||||
|
|
||||||
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
|
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
|
||||||
|
|
||||||
@@ -474,11 +523,13 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
|||||||
**Decision: No code is written until the product decision is made.**
|
**Decision: No code is written until the product decision is made.**
|
||||||
|
|
||||||
The core trade-off is:
|
The core trade-off is:
|
||||||
|
|
||||||
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
|
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
|
||||||
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
|
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
|
||||||
- **Short-lived API tokens (OAuth-style):** Suitable if CapaKraken exposes a public API. Over-engineered for an internal tool with no current integration story.
|
- **Short-lived API tokens (OAuth-style):** Suitable if Nexus exposes a public API. Over-engineered for an internal tool with no current integration story.
|
||||||
|
|
||||||
**Engineering guidance for when the decision is made:**
|
**Engineering guidance for when the decision is made:**
|
||||||
|
|
||||||
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
|
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
|
||||||
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
|
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
|
||||||
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
|
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/screenshots/dashboard-dark.jpeg" alt="CapaKraken Dashboard" width="100%" />
|
<img src="docs/screenshots/dashboard-dark.jpeg" alt="Nexus Dashboard" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">CapaKraken</h1>
|
<h1 align="center">Nexus</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Resource & Capacity Planning for 3D Production Studios</strong><br/>
|
<strong>Resource & Capacity Planning for 3D Production Studios</strong><br/>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
CapaKraken is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
Nexus is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
||||||
|
|
||||||
The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
|
The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ The application was designed from the ground up for the unique challenges of cre
|
|||||||
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
|
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
The timeline is the centerpiece of CapaKraken. It provides a visual, interactive view of all resource allocations across projects.
|
The timeline is the centerpiece of Nexus. It provides a visual, interactive view of all resource allocations across projects.
|
||||||
|
|
||||||
- **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
|
- **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
|
||||||
- **Project View** -- flip the perspective to see all resources assigned to each project
|
- **Project View** -- flip the perspective to see all resources assigned to each project
|
||||||
@@ -97,18 +97,18 @@ A structured list view of all allocations with:
|
|||||||
|
|
||||||
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
|
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
|
||||||
|
|
||||||
| Widget | Description |
|
| Widget | Description |
|
||||||
|--------|-------------|
|
| -------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
||||||
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
||||||
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
||||||
| **Project Overview** | All projects with cost, person days, and status badges |
|
| **Project Overview** | All projects with cost, person days, and status badges |
|
||||||
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
||||||
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
||||||
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
||||||
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
||||||
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
||||||
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
||||||
|
|
||||||
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
|
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
|
||||||
|
|
||||||
@@ -172,23 +172,23 @@ A full project estimation workflow:
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology | Purpose |
|
| Layer | Technology | Purpose |
|
||||||
|-------|-----------|---------|
|
| -------------------- | --------------------------------- | -------------------------------------------------------- |
|
||||||
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
||||||
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
||||||
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
||||||
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
||||||
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
||||||
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
||||||
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
||||||
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
||||||
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
||||||
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
||||||
|
|
||||||
### Monorepo Structure
|
### Monorepo Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
capakraken/
|
nexus/
|
||||||
|
|
|
|
||||||
+-- apps/
|
+-- apps/
|
||||||
| +-- web/ Next.js 15 application (frontend + API routes)
|
| +-- web/ Next.js 15 application (frontend + API routes)
|
||||||
@@ -263,18 +263,18 @@ capakraken/
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
| Requirement | Minimum Version | Check |
|
| Requirement | Minimum Version | Check |
|
||||||
|-------------|----------------|-------|
|
| ------------------ | --------------- | ------------------------ |
|
||||||
| **Node.js** | 20.x | `node --version` |
|
| **Node.js** | 20.x | `node --version` |
|
||||||
| **pnpm** | 9.x | `pnpm --version` |
|
| **pnpm** | 9.x | `pnpm --version` |
|
||||||
| **Docker** | 24+ | `docker --version` |
|
| **Docker** | 24+ | `docker --version` |
|
||||||
| **Docker Compose** | v2 | `docker compose version` |
|
| **Docker Compose** | v2 | `docker compose version` |
|
||||||
|
|
||||||
### 1. Clone and configure
|
### 1. Clone and configure
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git capakraken
|
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git nexus
|
||||||
cd capakraken
|
cd nexus
|
||||||
```
|
```
|
||||||
|
|
||||||
Create your environment file:
|
Create your environment file:
|
||||||
@@ -315,13 +315,13 @@ This single command will:
|
|||||||
You'll see output like:
|
You'll see output like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Starting CapaKraken...
|
Starting Nexus...
|
||||||
Starting PostgreSQL + Redis...
|
Starting PostgreSQL + Redis...
|
||||||
Waiting for PostgreSQL...
|
Waiting for PostgreSQL...
|
||||||
Starting app container on port 3100...
|
Starting app container on port 3100...
|
||||||
Waiting for server (up to 90s)...
|
Waiting for server (up to 90s)...
|
||||||
|
|
||||||
CapaKraken is running!
|
Nexus is running!
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
@@ -372,13 +372,13 @@ This populates the database with sample clients, projects, resources, allocation
|
|||||||
|
|
||||||
When running with Docker Compose, the following services are available:
|
When running with Docker Compose, the following services are available:
|
||||||
|
|
||||||
| Service | URL | Purpose |
|
| Service | URL | Purpose |
|
||||||
|---------|-----|---------|
|
| -------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
| **CapaKraken App** | [localhost:3100](http://localhost:3100) | Main application |
|
| **Nexus App** | [localhost:3100](http://localhost:3100) | Main application |
|
||||||
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
||||||
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
||||||
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `capakraken`, db: `capakraken`) |
|
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `nexus`, db: `nexus`) |
|
||||||
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -386,50 +386,50 @@ When running with Docker Compose, the following services are available:
|
|||||||
|
|
||||||
### Application Lifecycle
|
### Application Lifecycle
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ------------------------- | ------------------------------------------- |
|
||||||
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
||||||
| `bash scripts/stop.sh` | Stop all services gracefully |
|
| `bash scripts/stop.sh` | Stop all services gracefully |
|
||||||
| `bash scripts/restart.sh` | Full stop + start cycle |
|
| `bash scripts/restart.sh` | Full stop + start cycle |
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ------------------------- | -------------------------------------------------------- |
|
||||||
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
||||||
| `pnpm build` | Production build (standalone output) |
|
| `pnpm build` | Production build (standalone output) |
|
||||||
| `pnpm lint` | Run ESLint across all packages |
|
| `pnpm lint` | Run ESLint across all packages |
|
||||||
| `pnpm format` | Format all files with Prettier |
|
| `pnpm format` | Format all files with Prettier |
|
||||||
| `pnpm test:unit` | Run unit tests via Vitest |
|
| `pnpm test:unit` | Run unit tests via Vitest |
|
||||||
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
||||||
| `pnpm typecheck` | TypeScript type checking across all packages |
|
| `pnpm typecheck` | TypeScript type checking across all packages |
|
||||||
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
|
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| --------------------- | ------------------------------------------------ |
|
||||||
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
||||||
| `pnpm db:migrate` | Create and apply new migrations |
|
| `pnpm db:migrate` | Create and apply new migrations |
|
||||||
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
||||||
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
||||||
| `pnpm db:seed` | Seed the database with demo data |
|
| `pnpm db:seed` | Seed the database with demo data |
|
||||||
| `pnpm db:doctor` | Run health checks on database state |
|
| `pnpm db:doctor` | Run health checks on database state |
|
||||||
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
||||||
| `pnpm db:seed:import` | Import a previously exported seed file |
|
| `pnpm db:seed:import` | Import a previously exported seed file |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
CapaKraken ships with a production-ready Docker Compose stack and deployment automation.
|
Nexus ships with a production-ready Docker Compose stack and deployment automation.
|
||||||
|
|
||||||
### Quick Deploy
|
### Quick Deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configure required secrets
|
# Configure required secrets
|
||||||
export APP_IMAGE=ghcr.io/your-org/capakraken-app:latest
|
export APP_IMAGE=ghcr.io/your-org/nexus-app:latest
|
||||||
export MIGRATOR_IMAGE=ghcr.io/your-org/capakraken-migrator:latest
|
export MIGRATOR_IMAGE=ghcr.io/your-org/nexus-migrator:latest
|
||||||
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||||
export REDIS_PASSWORD=$(openssl rand -hex 32)
|
export REDIS_PASSWORD=$(openssl rand -hex 32)
|
||||||
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
||||||
@@ -454,35 +454,35 @@ bash tooling/deploy/deploy-compose.sh production
|
|||||||
|
|
||||||
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
|
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
| ---------------------- | -------- | -------------------- | ----------------------------------------------- |
|
||||||
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
||||||
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
||||||
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
||||||
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
||||||
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
||||||
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
||||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||||
| `SMTP_FROM` | No | `noreply@capakraken.dev` | Sender address for outgoing emails |
|
| `SMTP_FROM` | No | `noreply@nexus.dev` | Sender address for outgoing emails |
|
||||||
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
||||||
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
||||||
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
||||||
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
| Principle | Implementation |
|
| Principle | Implementation |
|
||||||
|-----------|---------------|
|
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||||
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
||||||
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
||||||
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
||||||
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
||||||
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
||||||
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
||||||
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
||||||
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
|
|||||||
4. Run quality gates before submitting:
|
4. Run quality gates before submitting:
|
||||||
```bash
|
```bash
|
||||||
pnpm test:unit
|
pnpm test:unit
|
||||||
pnpm --filter @capakraken/web exec tsc --noEmit
|
pnpm --filter @nexus/web exec tsc --noEmit
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm check:architecture
|
pnpm check:architecture
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from "./a11y-fixture.js";
|
|||||||
test.describe("Accessibility (axe-core)", () => {
|
test.describe("Accessibility (axe-core)", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
||||||
|
|||||||
+17
-14
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Admin Pages", () => {
|
test.describe("Admin Pages", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -12,39 +12,42 @@ test.describe("Admin Pages", () => {
|
|||||||
test("settings page loads", async ({ page }) => {
|
test("settings page loads", async ({ page }) => {
|
||||||
await page.goto("/admin/settings");
|
await page.goto("/admin/settings");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("users page loads with user list", async ({ page }) => {
|
test("users page loads with user list", async ({ page }) => {
|
||||||
await page.goto("/admin/users");
|
await page.goto("/admin/users");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
// Should show a table with at least the admin user
|
// Should show a table with at least the admin user
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator("text=admin@capakraken.dev")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("text=admin@nexus.dev")).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("roles page loads", async ({ page }) => {
|
test("roles page loads", async ({ page }) => {
|
||||||
await page.goto("/roles");
|
await page.goto("/roles");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1").filter({ hasText: /Roles/i })).toBeVisible({ timeout: 10000 });
|
||||||
page.locator("h1").filter({ hasText: /Roles/i }),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
// Should show table or list of roles
|
// Should show table or list of roles
|
||||||
await expect(
|
await expect(page.locator("table").or(page.locator("text=No roles"))).toBeVisible({
|
||||||
page.locator("table").or(page.locator("text=No roles")),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("blueprints page loads", async ({ page }) => {
|
test("blueprints page loads", async ({ page }) => {
|
||||||
await page.goto("/admin/blueprints");
|
await page.goto("/admin/blueprints");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1").filter({ hasText: /Blueprints/i })).toBeVisible({
|
||||||
page.locator("h1").filter({ hasText: /Blueprints/i }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
// Should show blueprint cards or list from seed data
|
// Should show blueprint cards or list from seed data
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("table")
|
page
|
||||||
|
.locator("table")
|
||||||
.or(page.locator("text=3D Content Production"))
|
.or(page.locator("text=3D Content Production"))
|
||||||
.or(page.locator("text=No blueprints")),
|
.or(page.locator("text=No blueprints")),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -40,24 +40,28 @@ async function signIn(page: Page, email: string, password: string) {
|
|||||||
test.describe("Allocations", () => {
|
test.describe("Allocations", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await freezeBrowserTime(page);
|
await freezeBrowserTime(page);
|
||||||
await signIn(page, "admin@capakraken.dev", "admin123");
|
await signIn(page, "admin@nexus.dev", "admin123");
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
||||||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
||||||
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
|
test("explicitly restrictive filters show a visible empty state and can be reset", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const projectFilter = page.getByPlaceholder("Filter by project…");
|
const projectFilter = page.getByPlaceholder("Filter by project…");
|
||||||
@@ -83,21 +87,23 @@ test.describe("Allocations", () => {
|
|||||||
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
||||||
await newBtn.click();
|
await newBtn.click();
|
||||||
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
||||||
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole("heading", { name: /New (Assignment|Open Demand)/i }),
|
||||||
|
).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter by status works", async ({ page }) => {
|
test("filter by status works", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Look for status filter chips or dropdown
|
// Look for status filter chips or dropdown
|
||||||
const statusFilter = page.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i }).first();
|
const statusFilter = page
|
||||||
|
.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i })
|
||||||
|
.first();
|
||||||
if ((await statusFilter.count()) > 0) {
|
if ((await statusFilter.count()) > 0) {
|
||||||
await statusFilter.click();
|
await statusFilter.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
// After clicking a status filter, the page should still show the table
|
// After clicking a status filter, the page should still show the table
|
||||||
await expect(
|
await expect(page.locator("table").or(page.locator("text=No allocations"))).toBeVisible();
|
||||||
page.locator("table").or(page.locator("text=No allocations")),
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,17 +114,17 @@ test.describe("Allocations", () => {
|
|||||||
await colToggle.click();
|
await colToggle.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
// A panel or dropdown with column checkboxes should appear
|
// A panel or dropdown with column checkboxes should appear
|
||||||
await expect(
|
await expect(page.locator("input[type='checkbox']").first()).toBeVisible({ timeout: 3000 });
|
||||||
page.locator("input[type='checkbox']").first(),
|
|
||||||
).toBeVisible({ timeout: 3000 });
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
|
test("viewer sees a visible access error instead of an empty allocations page", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await freezeBrowserTime(page);
|
await freezeBrowserTime(page);
|
||||||
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
await signIn(page, "viewer@nexus.dev", "viewer123");
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -71,8 +71,8 @@ test.describe("Analytics / Insights", () => {
|
|||||||
test("insights page loads without errors", async ({ page }) => {
|
test("insights page loads without errors", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Page should render some heading or content area — not a hard 404
|
// Page should render some heading or content area — not a hard 404
|
||||||
await expect(
|
await expect(page.locator("h1").or(page.locator("main")).first()).toBeVisible({
|
||||||
page.locator("h1").or(page.locator("main")).first(),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const ADMIN_EMAIL = "admin@capakraken.dev";
|
const ADMIN_EMAIL = "admin@nexus.dev";
|
||||||
const ADMIN_PASSWORD = "admin123";
|
const ADMIN_PASSWORD = "admin123";
|
||||||
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
||||||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||||||
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.addInitScript((conversationId) => {
|
await page.addInitScript((conversationId) => {
|
||||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
|
||||||
}, CURRENT_CONVERSATION_ID);
|
}, CURRENT_CONVERSATION_ID);
|
||||||
|
|
||||||
runDb(`
|
runDb(`
|
||||||
@@ -159,7 +159,9 @@ test.describe("Assistant approvals", () => {
|
|||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
|
test("renders the pending approval inbox and handles cross-conversation actions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const suffix = Date.now();
|
const suffix = Date.now();
|
||||||
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
||||||
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
||||||
@@ -210,14 +212,22 @@ test.describe("Assistant approvals", () => {
|
|||||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||||
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
||||||
|
|
||||||
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
|
const currentCard = page
|
||||||
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
|
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]')
|
||||||
|
.first();
|
||||||
|
const otherCard = page
|
||||||
|
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]')
|
||||||
|
.first();
|
||||||
await expect(currentCard).toContainText("This chat");
|
await expect(currentCard).toContainText("This chat");
|
||||||
await expect(otherCard).toContainText("Other chat");
|
await expect(otherCard).toContainText("Other chat");
|
||||||
|
|
||||||
await otherCard.getByTestId("assistant-approval-cancel").click();
|
await otherCard.getByTestId("assistant-approval-cancel").click();
|
||||||
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
||||||
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`,
|
||||||
|
),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test.describe("Authentication", () => {
|
|||||||
|
|
||||||
test("admin can sign in", async ({ page }) => {
|
test("admin can sign in", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,9 +16,7 @@ test.describe("Bench Board", () => {
|
|||||||
|
|
||||||
test("bench board page loads with heading", async ({ page }) => {
|
test("bench board page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1", { hasText: "Bench Board" })).toBeVisible({ timeout: 10000 });
|
||||||
page.locator("h1", { hasText: "Bench Board" }),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("date range filter inputs are visible", async ({ page }) => {
|
test("date range filter inputs are visible", async ({ page }) => {
|
||||||
@@ -32,7 +30,8 @@ test.describe("Bench Board", () => {
|
|||||||
test("shows bench results or no-resources empty state", async ({ page }) => {
|
test("shows bench results or no-resources empty state", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("table")
|
page
|
||||||
|
.locator("table")
|
||||||
.or(page.locator("text=No resources on bench"))
|
.or(page.locator("text=No resources on bench"))
|
||||||
.or(page.locator("text=No results"))
|
.or(page.locator("text=No results"))
|
||||||
.first(),
|
.first(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Dashboard", () => {
|
test.describe("Dashboard", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -31,7 +31,9 @@ test.describe("Dashboard", () => {
|
|||||||
const addBtn = page.locator("button", { hasText: /Add Widget/i });
|
const addBtn = page.locator("button", { hasText: /Add Widget/i });
|
||||||
if ((await addBtn.count()) > 0) {
|
if ((await addBtn.count()) > 0) {
|
||||||
await addBtn.click();
|
await addBtn.click();
|
||||||
await expect(page.locator("text=Add Widget").or(page.locator("text=Available Widgets"))).toBeVisible();
|
await expect(
|
||||||
|
page.locator("text=Add Widget").or(page.locator("text=Available Widgets")),
|
||||||
|
).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
|
|||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||||
// Error message visible
|
// Error message visible
|
||||||
await expect(
|
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
|
||||||
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
|
timeout: 5000,
|
||||||
).toBeVisible({ timeout: 5000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
||||||
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
|
|||||||
|
|
||||||
// At least one user row should be visible
|
// At least one user row should be visible
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
await expect(page.locator("text=No users found")).toHaveCount(0);
|
await expect(page.locator("text=No users found")).toHaveCount(0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
||||||
* - Cleans up the test user in afterAll.
|
* - Cleans up the test user in afterAll.
|
||||||
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
||||||
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
|
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||||
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
|||||||
|
|
||||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
type TrpcResult = {
|
||||||
|
result?: { data?: unknown };
|
||||||
|
error?: { data?: { code?: string }; message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcMutation(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||||
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcQuery(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||||
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Default layout should show at least the stat-cards widget
|
// Default layout should show at least the stat-cards widget
|
||||||
// (from createDefaultDashboardLayout in useDashboardLayout)
|
// (from createDefaultDashboardLayout in useDashboardLayout)
|
||||||
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
|
await expect(
|
||||||
|
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 8000,
|
timeout: 8000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
await navigateToDashboard(page);
|
await navigateToDashboard(page);
|
||||||
|
|
||||||
// Open modal
|
// Open modal
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Verify modal is open
|
// Verify modal is open
|
||||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Verify widget entries are visible in the modal
|
// Verify widget entries are visible in the modal
|
||||||
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
||||||
const widgetButtons = page.locator(
|
const widgetButtons = page
|
||||||
'[role="dialog"] button, .fixed button[type="button"]',
|
.locator('[role="dialog"] button, .fixed button[type="button"]')
|
||||||
).filter({ hasText: /./ });
|
.filter({ hasText: /./ });
|
||||||
|
|
||||||
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
||||||
const modalContent = page.locator(".fixed.inset-0 .grid");
|
const modalContent = page.locator(".fixed.inset-0 .grid");
|
||||||
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
const initialCount = await page.locator(".react-grid-item").count();
|
const initialCount = await page.locator(".react-grid-item").count();
|
||||||
|
|
||||||
// Open modal and add "Resource Table" widget
|
// Open modal and add "Resource Table" widget
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
|
await page
|
||||||
|
.locator(".fixed.inset-0 button")
|
||||||
|
.filter({ hasText: /resource table/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Modal should close after adding
|
// Modal should close after adding
|
||||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||||
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
await navigateToDashboard(page);
|
await navigateToDashboard(page);
|
||||||
|
|
||||||
// Add a recognizable widget
|
// Add a recognizable widget
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
|
await page
|
||||||
|
.locator(".fixed.inset-0 button")
|
||||||
|
.filter({ hasText: /project overview/i })
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
const countAfterAdd = await page.locator(".react-grid-item").count();
|
const countAfterAdd = await page.locator(".react-grid-item").count();
|
||||||
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Read the admin's localStorage key to verify it is user-scoped
|
// Read the admin's localStorage key to verify it is user-scoped
|
||||||
const adminUserId = await adminPage.evaluate(async () => {
|
const adminUserId = await adminPage.evaluate(async () => {
|
||||||
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
|
const res = await fetch(
|
||||||
credentials: "include",
|
"/api/trpc/user.me?batch=1&input=" +
|
||||||
});
|
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
|
||||||
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
|
{
|
||||||
|
credentials: "include",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||||
return body[0]?.result?.data?.json?.id ?? null;
|
return body[0]?.result?.data?.json?.id ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
|
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
|
||||||
if (adminUserId) {
|
if (adminUserId) {
|
||||||
const storageKey = await adminPage.evaluate((userId) => {
|
const storageKey = await adminPage.evaluate((userId) => {
|
||||||
// Check both old (unscoped) and new (user-scoped) key formats
|
// Check both old (unscoped) and new (user-scoped) key formats
|
||||||
const oldKey = "capakraken_dashboard_v1";
|
const oldKey = "nexus_dashboard_v1";
|
||||||
const newKey = `capakraken_dashboard_v1_${userId}`;
|
const newKey = `nexus_dashboard_v1_${userId}`;
|
||||||
const oldValue = localStorage.getItem(oldKey);
|
const oldValue = localStorage.getItem(oldKey);
|
||||||
const newValue = localStorage.getItem(newKey);
|
const newValue = localStorage.getItem(newKey);
|
||||||
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
||||||
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Inject the admin's storage key to simulate same browser
|
// Inject the admin's storage key to simulate same browser
|
||||||
await newUserPage.evaluate(
|
await newUserPage.evaluate(
|
||||||
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
|
({ key, value }) => {
|
||||||
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
|
localStorage.setItem(key, value ?? "");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `nexus_dashboard_v1_${adminUserId}`,
|
||||||
|
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log in as test user
|
// Log in as test user
|
||||||
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
||||||
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
||||||
// The key check: the test user's Add Widget button should still work
|
// The key check: the test user's Add Widget button should still work
|
||||||
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
|
await newUserPage
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Modal must show widgets to choose from
|
// Modal must show widgets to choose from
|
||||||
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
|
|||||||
password: "Dev123456!",
|
password: "Dev123456!",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DB_CONTAINER = "capakraken-postgres-1";
|
const DB_CONTAINER = "nexus-postgres-1";
|
||||||
const DB_USER = "capakraken";
|
const DB_USER = "nexus";
|
||||||
const DB_NAME = "capakraken";
|
const DB_NAME = "nexus";
|
||||||
|
|
||||||
function psqlExec(sql: string): string {
|
function psqlExec(sql: string): string {
|
||||||
return execSync(
|
return execSync(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
|
|||||||
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const csrfRes = await fetch("/api/auth/csrf");
|
const csrfRes = await fetch("/api/auth/csrf");
|
||||||
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
|
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
|
||||||
await fetch("/api/auth/signout", {
|
await fetch("/api/auth/signout", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -62,11 +62,9 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
|
|||||||
const enc = (encoding ?? "").toLowerCase().trim();
|
const enc = (encoding ?? "").toLowerCase().trim();
|
||||||
if (enc === "quoted-printable") {
|
if (enc === "quoted-printable") {
|
||||||
return body
|
return body
|
||||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||||
.replace(/=\n/g, "") // soft line break (LF)
|
.replace(/=\n/g, "") // soft line break (LF)
|
||||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
String.fromCharCode(parseInt(hex, 16)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (enc === "base64") {
|
if (enc === "base64") {
|
||||||
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
||||||
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function getLatestEmailTo(
|
export async function getLatestEmailTo(
|
||||||
address: string,
|
address: string,
|
||||||
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
{
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
pollIntervalMs = 500,
|
||||||
|
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||||
): Promise<{ subject: string; body: string; html: string }> {
|
): Promise<{ subject: string; body: string; html: string }> {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
|
|||||||
pathPrefix: string,
|
pathPrefix: string,
|
||||||
): string {
|
): string {
|
||||||
const text = email.html || email.body;
|
const text = email.html || email.body;
|
||||||
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
|
const match = text.match(
|
||||||
|
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
|
||||||
|
);
|
||||||
if (!match?.[0]) {
|
if (!match?.[0]) {
|
||||||
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
||||||
}
|
}
|
||||||
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
|
|||||||
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
||||||
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
||||||
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
||||||
execSync(
|
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
|
||||||
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
|
input: sql,
|
||||||
{ input: sql, encoding: "utf8" },
|
encoding: "utf8",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
||||||
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
|
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
|
||||||
|
|
||||||
// Step 1: Navigate to admin users page
|
// Step 1: Navigate to admin users page
|
||||||
await page.goto("/admin/users");
|
await page.goto("/admin/users");
|
||||||
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
|
|||||||
// Step 2: Open invite modal
|
// Step 2: Open invite modal
|
||||||
await page.click('button:has-text("Invite User")');
|
await page.click('button:has-text("Invite User")');
|
||||||
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
||||||
await page.waitForSelector('text=Invite User', { state: "visible" });
|
await page.waitForSelector("text=Invite User", { state: "visible" });
|
||||||
|
|
||||||
// Step 3: Fill in invite form
|
// Step 3: Fill in invite form
|
||||||
await page.fill('input[type="email"]', testEmail);
|
await page.fill('input[type="email"]', testEmail);
|
||||||
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
|
|||||||
await page.click('button:has-text("Send Invite")');
|
await page.click('button:has-text("Send Invite")');
|
||||||
|
|
||||||
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
||||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 6: Read invite email from Mailhog
|
// Step 6: Read invite email from Mailhog
|
||||||
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
||||||
|
|||||||
@@ -21,9 +21,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
|||||||
|
|
||||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
type TrpcResult = {
|
||||||
|
result?: { data?: unknown };
|
||||||
|
error?: { data?: { code?: string }; message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcMutation(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||||
@@ -39,7 +46,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcQuery(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||||
@@ -60,7 +71,7 @@ async function enableMfaForSession(page: Page): Promise<TOTP> {
|
|||||||
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
||||||
|
|
||||||
const totp = new TOTP({
|
const totp = new TOTP({
|
||||||
issuer: "CapaKraken",
|
issuer: "Nexus",
|
||||||
algorithm: "SHA1",
|
algorithm: "SHA1",
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
@@ -92,7 +103,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
// Clean up: disable MFA if a test enabled it
|
// Clean up: disable MFA if a test enabled it
|
||||||
if (totp) {
|
if (totp) {
|
||||||
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */});
|
await disableMfaForSession(page).catch(() => {
|
||||||
|
/* already disabled or admin override */
|
||||||
|
});
|
||||||
totp = null;
|
totp = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -106,7 +119,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
|
|
||||||
expect(data?.secret).toBeTruthy();
|
expect(data?.secret).toBeTruthy();
|
||||||
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
||||||
expect(data?.uri).toContain("CapaKraken");
|
expect(data?.uri).toContain("Nexus");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
||||||
@@ -137,9 +150,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Click the enable/setup button if MFA is not yet enabled
|
// Click the enable/setup button if MFA is not yet enabled
|
||||||
const setupBtn = page.getByRole("button", { name: /set up/i }).or(
|
const setupBtn = page
|
||||||
page.getByRole("button", { name: /enable.*mfa/i }),
|
.getByRole("button", { name: /set up/i })
|
||||||
);
|
.or(page.getByRole("button", { name: /enable.*mfa/i }));
|
||||||
|
|
||||||
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||||
await setupBtn.click();
|
await setupBtn.click();
|
||||||
@@ -233,9 +246,10 @@ test.describe("MFA — login flow", () => {
|
|||||||
|
|
||||||
// Should show error and remain on TOTP step
|
// Should show error and remain on TOTP step
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText(/invalid.*code|incorrect.*token|try again/i).or(
|
page
|
||||||
page.locator("[data-error]"),
|
.getByText(/invalid.*code|incorrect.*token|try again/i)
|
||||||
).first(),
|
.or(page.locator("[data-error]"))
|
||||||
|
.first(),
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Should NOT have navigated away
|
// Should NOT have navigated away
|
||||||
@@ -248,7 +262,9 @@ test.describe("MFA — login flow", () => {
|
|||||||
test.describe("MFA — users without MFA enabled", () => {
|
test.describe("MFA — users without MFA enabled", () => {
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => {
|
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
|
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* pnpm --filter @capakraken/web exec playwright test \
|
* pnpm --filter @nexus/web exec playwright test \
|
||||||
* --config playwright.dev.config.ts \
|
* --config playwright.dev.config.ts \
|
||||||
* e2e/dev-system/nav-smoke.spec.ts
|
* e2e/dev-system/nav-smoke.spec.ts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ test.describe("RBAC — admin routes (admin session)", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
// Seed users have planarchy.dev or capakraken.dev email domains
|
// Seed users have planarchy.dev or nexus.dev email domains
|
||||||
await expect(
|
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||||
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
||||||
@@ -99,9 +99,10 @@ test.describe("RBAC — allocations permitted for admin", () => {
|
|||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(
|
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
||||||
page.locator("text=/do not have permission to view allocations/i"),
|
0,
|
||||||
).toHaveCount(0, { timeout: 8000 });
|
{ timeout: 8000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,9 +113,10 @@ test.describe("RBAC — allocations permitted for manager", () => {
|
|||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(
|
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
||||||
page.locator("text=/do not have permission to view allocations/i"),
|
0,
|
||||||
).toHaveCount(0, { timeout: 8000 });
|
{ timeout: 8000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,26 @@ async function signIn(page: Page, email: string, password: string) {
|
|||||||
|
|
||||||
test.describe("Estimates", () => {
|
test.describe("Estimates", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await signIn(page, "admin@capakraken.dev", "admin123");
|
await signIn(page, "admin@nexus.dev", "admin123");
|
||||||
await page.goto("/estimates");
|
await page.goto("/estimates");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("estimate list loads", async ({ page }) => {
|
test("estimate list loads", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.getByRole("heading", { name: /estimate workspace/i })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
await expect(page.getByPlaceholder("Search by estimate or opportunity")).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: /estimate workspace/i }),
|
page
|
||||||
).toBeVisible({ timeout: 10000 });
|
.locator("text=No estimates yet")
|
||||||
await expect(
|
.or(
|
||||||
page.getByPlaceholder("Search by estimate or opportunity"),
|
page.locator(
|
||||||
).toBeVisible({ timeout: 10000 });
|
"text=Select an estimate to inspect the current version, demand lines, and summary metrics.",
|
||||||
await expect(
|
),
|
||||||
page.locator("text=No estimates yet").or(
|
),
|
||||||
page.locator("text=Select an estimate to inspect the current version, demand lines, and summary metrics."),
|
|
||||||
),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,8 +48,13 @@ test.describe("Estimates", () => {
|
|||||||
await page.locator("button", { hasText: /New Estimate/i }).click();
|
await page.locator("button", { hasText: /New Estimate/i }).click();
|
||||||
|
|
||||||
// Step 1: Setup — fill a name
|
// Step 1: Setup — fill a name
|
||||||
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({
|
||||||
const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
const nameInput = page
|
||||||
|
.locator('input[placeholder*="name"]')
|
||||||
|
.or(page.locator('input[name="name"]'))
|
||||||
|
.first();
|
||||||
if ((await nameInput.count()) > 0) {
|
if ((await nameInput.count()) > 0) {
|
||||||
await nameInput.fill(`E2E Estimate ${Date.now()}`);
|
await nameInput.fill(`E2E Estimate ${Date.now()}`);
|
||||||
}
|
}
|
||||||
@@ -90,9 +99,7 @@ test.describe("Estimates", () => {
|
|||||||
|
|
||||||
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
|
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("text=No estimates yet")).toBeVisible({ timeout: 10000 });
|
||||||
page.locator("text=No estimates yet"),
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
|
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
|
||||||
@@ -103,12 +110,14 @@ test.describe("Estimates", () => {
|
|||||||
|
|
||||||
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
|
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
await signIn(page, "viewer@nexus.dev", "viewer123");
|
||||||
await page.goto("/estimates/missing-estimate");
|
await page.goto("/estimates/missing-estimate");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=Your role can access the estimate list, but not the detailed financial workspace."),
|
page.locator(
|
||||||
|
"text=Your role can access the estimate list, but not the detailed financial workspace.",
|
||||||
|
),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Holiday Calendar Editor", () => {
|
test.describe("Holiday Calendar Editor", () => {
|
||||||
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
|
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const suffix = Date.now().toString();
|
const suffix = Date.now().toString();
|
||||||
const calendarName = `E2E City Calendar ${suffix}`;
|
const calendarName = `E2E City Calendar ${suffix}`;
|
||||||
const holidayName = `E2E Local Holiday ${suffix}`;
|
const holidayName = `E2E Local Holiday ${suffix}`;
|
||||||
@@ -21,11 +23,18 @@ test.describe("Holiday Calendar Editor", () => {
|
|||||||
|
|
||||||
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
||||||
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
||||||
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
|
await page
|
||||||
|
.getByTestId("holiday-calendar-country-select")
|
||||||
|
.selectOption({ label: "Germany (DE)" });
|
||||||
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
||||||
await page.getByTestId("holiday-calendar-create-button").click();
|
await page.getByTestId("holiday-calendar-create-button").click();
|
||||||
|
|
||||||
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId(/holiday-calendar-row-/)
|
||||||
|
.filter({ hasText: calendarName })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
||||||
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
||||||
|
|
||||||
@@ -44,10 +53,15 @@ test.describe("Holiday Calendar Editor", () => {
|
|||||||
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
||||||
await page.getByTestId("holiday-entry-create-button").click();
|
await page.getByTestId("holiday-entry-create-button").click();
|
||||||
|
|
||||||
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("A holiday entry for this calendar and date already exists"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
page.once("dialog", (dialog) => dialog.accept());
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
await page.getByTestId(/holiday-entry-delete-/).first().click();
|
await page
|
||||||
|
.getByTestId(/holiday-entry-delete-/)
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
||||||
|
|
||||||
page.once("dialog", (dialog) => dialog.accept());
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Navigation", () => {
|
test.describe("Navigation", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -28,7 +28,7 @@ test.describe("Navigation", () => {
|
|||||||
|
|
||||||
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
|
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
|
||||||
// Complements the click-based test above with a direct-navigation check
|
// Complements the click-based test above with a direct-navigation check
|
||||||
// covering every sidebar destination. Uses admin@capakraken.dev (ADMIN role).
|
// covering every sidebar destination. Uses admin@nexus.dev (ADMIN role).
|
||||||
const routes = [
|
const routes = [
|
||||||
// Already covered by click test but included for completeness
|
// Already covered by click test but included for completeness
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
@@ -79,7 +79,10 @@ test.describe("Navigation", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand again — the button should still be visible as an icon
|
// Expand again — the button should still be visible as an icon
|
||||||
const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last();
|
const expandBtn = page
|
||||||
|
.locator("nav button")
|
||||||
|
.filter({ has: page.locator("svg") })
|
||||||
|
.last();
|
||||||
await expandBtn.click();
|
await expandBtn.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const boxExpanded = await nav.boundingBox();
|
const boxExpanded = await nav.boundingBox();
|
||||||
@@ -113,7 +116,10 @@ test.describe("Navigation", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// The hamburger button should be visible on mobile
|
// The hamburger button should be visible on mobile
|
||||||
const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first();
|
const hamburgerBtn = page
|
||||||
|
.locator("button")
|
||||||
|
.filter({ has: page.locator("svg") })
|
||||||
|
.first();
|
||||||
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
|
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await hamburgerBtn.click();
|
await hamburgerBtn.click();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -74,9 +74,9 @@ test.describe("Project Detail Page", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// BudgetStatusCard renders budget-related content
|
// BudgetStatusCard renders budget-related content
|
||||||
await expect(
|
await expect(page.locator("text=Budget").or(page.locator("text=budget")).first()).toBeVisible({
|
||||||
page.locator("text=Budget").or(page.locator("text=budget")).first(),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unknown project id shows not-found state", async ({ page }) => {
|
test("unknown project id shows not-found state", async ({ page }) => {
|
||||||
@@ -85,7 +85,11 @@ test.describe("Project Detail Page", () => {
|
|||||||
|
|
||||||
// Server-side notFound() triggers the Next.js 404 page
|
// Server-side notFound() triggers the Next.js 404 page
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=404").or(page.locator("text=Not Found")).or(page.locator("text=not found")).first(),
|
page
|
||||||
|
.locator("text=404")
|
||||||
|
.or(page.locator("text=Not Found"))
|
||||||
|
.or(page.locator("text=not found"))
|
||||||
|
.first(),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Projects", () => {
|
test.describe("Projects", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
await page.fill('input[type="email"]', "manager@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/resources/);
|
await expect(page).toHaveURL(/\/resources/);
|
||||||
@@ -26,9 +26,16 @@ test.describe("Projects", () => {
|
|||||||
// Step 1: Blueprint selection
|
// Step 1: Blueprint selection
|
||||||
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
||||||
// Select the first available blueprint
|
// Select the first available blueprint
|
||||||
const blueprintCard = page.locator("[data-blueprint-id]").first()
|
const blueprintCard = page
|
||||||
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first());
|
.locator("[data-blueprint-id]")
|
||||||
if (await blueprintCard.count() > 0) {
|
.first()
|
||||||
|
.or(
|
||||||
|
page
|
||||||
|
.locator("button")
|
||||||
|
.filter({ hasText: /Blueprint|Production/ })
|
||||||
|
.first(),
|
||||||
|
);
|
||||||
|
if ((await blueprintCard.count()) > 0) {
|
||||||
await blueprintCard.click();
|
await blueprintCard.click();
|
||||||
} else {
|
} else {
|
||||||
// Click next without blueprint if none shown
|
// Click next without blueprint if none shown
|
||||||
@@ -37,16 +44,21 @@ test.describe("Projects", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Timeline — set project dates
|
// Step 2: Timeline — set project dates
|
||||||
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 });
|
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({
|
||||||
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
timeout: 5000,
|
||||||
if (await projectNameInput.count() > 0) {
|
});
|
||||||
|
const projectNameInput = page
|
||||||
|
.locator('input[placeholder*="name"]')
|
||||||
|
.or(page.locator('input[name="name"]'))
|
||||||
|
.first();
|
||||||
|
if ((await projectNameInput.count()) > 0) {
|
||||||
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
||||||
}
|
}
|
||||||
await page.locator("button", { hasText: "Next" }).click();
|
await page.locator("button", { hasText: "Next" }).click();
|
||||||
|
|
||||||
// Step 3: Staffing demand
|
// Step 3: Staffing demand
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles")))
|
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles"))),
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
await page.locator("button", { hasText: "Next" }).click();
|
await page.locator("button", { hasText: "Next" }).click();
|
||||||
|
|
||||||
@@ -56,11 +68,13 @@ test.describe("Projects", () => {
|
|||||||
|
|
||||||
// Step 5: Review
|
// Step 5: Review
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
const reviewOrFinish = page.locator("text=Review").or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
const reviewOrFinish = page
|
||||||
|
.locator("text=Review")
|
||||||
|
.or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
||||||
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
||||||
// Don't actually submit — just close
|
// Don't actually submit — just close
|
||||||
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
||||||
if (await cancelBtn.count() > 0) {
|
if ((await cancelBtn.count()) > 0) {
|
||||||
await cancelBtn.click();
|
await cancelBtn.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,16 +16,17 @@ test.describe("Chargeability Report", () => {
|
|||||||
|
|
||||||
test("chargeability forecast page loads with heading", async ({ page }) => {
|
test("chargeability forecast page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1", { hasText: "Chargeability Forecast" })).toBeVisible({
|
||||||
page.locator("h1", { hasText: "Chargeability Forecast" }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter controls are present", async ({ page }) => {
|
test("filter controls are present", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Should have at least one filter (e.g., chapter, period, resource search)
|
// Should have at least one filter (e.g., chapter, period, resource search)
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('input[type="text"]')
|
page
|
||||||
|
.locator('input[type="text"]')
|
||||||
.or(page.locator('input[type="search"]'))
|
.or(page.locator('input[type="search"]'))
|
||||||
.or(page.locator("select"))
|
.or(page.locator("select"))
|
||||||
.first(),
|
.first(),
|
||||||
@@ -64,9 +65,9 @@ test.describe("Report Builder", () => {
|
|||||||
|
|
||||||
test("report builder page loads with heading", async ({ page }) => {
|
test("report builder page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.getByRole("heading", { name: "Report Builder" })).toBeVisible({
|
||||||
page.getByRole("heading", { name: "Report Builder" }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("entity selector is present with expected options", async ({ page }) => {
|
test("entity selector is present with expected options", async ({ page }) => {
|
||||||
@@ -78,9 +79,9 @@ test.describe("Report Builder", () => {
|
|||||||
|
|
||||||
test("run report button is visible", async ({ page }) => {
|
test("run report button is visible", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("button", { hasText: /Run|Export|Generate/i }).first()).toBeVisible({
|
||||||
page.locator("button", { hasText: /Run|Export|Generate/i }).first(),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("running a default report produces output or empty state", async ({ page }) => {
|
test("running a default report produces output or empty state", async ({ page }) => {
|
||||||
@@ -90,7 +91,11 @@ test.describe("Report Builder", () => {
|
|||||||
await runBtn.click();
|
await runBtn.click();
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("table").or(page.locator("text=No rows")).or(page.locator("text=0 rows")).first(),
|
page
|
||||||
|
.locator("table")
|
||||||
|
.or(page.locator("text=No rows"))
|
||||||
|
.or(page.locator("text=0 rows"))
|
||||||
|
.first(),
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Resources", () => {
|
test.describe("Resources", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
await page.fill('input[type="email"]', "manager@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "manager123");
|
await page.fill('input[type="password"]', "manager123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -21,10 +21,11 @@ test.describe("Resources", () => {
|
|||||||
await expect(rows.first()).toBeVisible();
|
await expect(rows.first()).toBeVisible();
|
||||||
|
|
||||||
const firstRowText = (await rows.first().textContent()) ?? "";
|
const firstRowText = (await rows.first().textContent()) ?? "";
|
||||||
const searchTerm = firstRowText
|
const searchTerm =
|
||||||
.split(/\s+/)
|
firstRowText
|
||||||
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
.split(/\s+/)
|
||||||
.find((token) => token.length >= 3) ?? "EMP";
|
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
||||||
|
.find((token) => token.length >= 3) ?? "EMP";
|
||||||
|
|
||||||
const searchInput = page.locator('input[type="search"]');
|
const searchInput = page.locator('input[type="search"]');
|
||||||
await searchInput.fill(searchTerm);
|
await searchInput.fill(searchTerm);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signIn(page: Page) {
|
async function signIn(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -16,15 +16,16 @@ test.describe("Scenario Planning", () => {
|
|||||||
|
|
||||||
test("scenarios page loads with heading", async ({ page }) => {
|
test("scenarios page loads with heading", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("h1", { hasText: /Scenario Planning/i })).toBeVisible({
|
||||||
page.locator("h1", { hasText: /Scenario Planning/i }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows scenarios list or empty state", async ({ page }) => {
|
test("shows scenarios list or empty state", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("table")
|
page
|
||||||
|
.locator("table")
|
||||||
.or(page.locator("text=No scenarios"))
|
.or(page.locator("text=No scenarios"))
|
||||||
.or(page.locator("text=Create a project first"))
|
.or(page.locator("text=Create a project first"))
|
||||||
.or(page.locator("[data-testid]"))
|
.or(page.locator("[data-testid]"))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test("signin page renders credential inputs and submit button", async ({ page })
|
|||||||
|
|
||||||
test("admin login succeeds and redirects away from signin", async ({ page }) => {
|
test("admin login succeeds and redirects away from signin", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||||
@@ -37,7 +37,7 @@ test("admin login succeeds and redirects away from signin", async ({ page }) =>
|
|||||||
|
|
||||||
test("authenticated user sees app shell nav", async ({ page }) => {
|
test("authenticated user sees app shell nav", async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
test.describe("Staffing", () => {
|
test.describe("Staffing", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -12,7 +12,9 @@ test.describe("Staffing", () => {
|
|||||||
|
|
||||||
test("staffing page loads with search form", async ({ page }) => {
|
test("staffing page loads with search form", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
// Search form should have skill input, date fields, and a search button
|
// Search form should have skill input, date fields, and a search button
|
||||||
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
@@ -20,9 +22,9 @@ test.describe("Staffing", () => {
|
|||||||
test("search form has default skill tags", async ({ page }) => {
|
test("search form has default skill tags", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// The StaffingPanel pre-populates with TypeScript and React skill tags
|
// The StaffingPanel pre-populates with TypeScript and React skill tags
|
||||||
await expect(
|
await expect(page.locator("text=TypeScript").or(page.locator("text=React"))).toBeVisible({
|
||||||
page.locator("text=TypeScript").or(page.locator("text=React")),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitting search returns suggestions or empty state", async ({ page }) => {
|
test("submitting search returns suggestions or empty state", async ({ page }) => {
|
||||||
@@ -34,7 +36,9 @@ test.describe("Staffing", () => {
|
|||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
// After search, should show either suggestion cards or a "no suggestions" message
|
// After search, should show either suggestion cards or a "no suggestions" message
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=/Score|Availability|No suggestions|No matching/i").first()
|
page
|
||||||
|
.locator("text=/Score|Availability|No suggestions|No matching/i")
|
||||||
|
.first()
|
||||||
.or(page.locator("[data-suggestion]").first())
|
.or(page.locator("[data-suggestion]").first())
|
||||||
.or(page.locator("table").first()),
|
.or(page.locator("table").first()),
|
||||||
).toBeVisible({ timeout: 15000 });
|
).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
|
|||||||
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
||||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
|
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
|
||||||
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
||||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
const composeProjectName = `nexus-e2e-${process.pid}`;
|
||||||
const managedEnvKeys = [
|
const managedEnvKeys = [
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
@@ -29,7 +29,7 @@ const managedEnvKeys = [
|
|||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"PORT",
|
"PORT",
|
||||||
];
|
];
|
||||||
const e2eComposePrefix = "capakraken-e2e-";
|
const e2eComposePrefix = "nexus-e2e-";
|
||||||
|
|
||||||
function dockerComposeArgs(...args) {
|
function dockerComposeArgs(...args) {
|
||||||
return ["compose", "-p", composeProjectName, ...args];
|
return ["compose", "-p", composeProjectName, ...args];
|
||||||
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
|
|||||||
try {
|
try {
|
||||||
await runQuiet(
|
await runQuiet(
|
||||||
"docker",
|
"docker",
|
||||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
|
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
|||||||
if (selectedTestDbPort !== undefined) {
|
if (selectedTestDbPort !== undefined) {
|
||||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||||
}
|
}
|
||||||
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
|
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||||
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
||||||
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
||||||
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||||
@@ -393,9 +393,9 @@ try {
|
|||||||
await cleanupStaleE2eArtifacts();
|
await cleanupStaleE2eArtifacts();
|
||||||
await ensureE2eDatabaseContainer();
|
await ensureE2eDatabaseContainer();
|
||||||
}
|
}
|
||||||
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
|
await run("pnpm", ["--filter", "@nexus/db", "db:push"], workspaceRoot);
|
||||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
|
await run("pnpm", ["--filter", "@nexus/db", "db:seed"], workspaceRoot);
|
||||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
|
await run("pnpm", ["--filter", "@nexus/db", "db:seed:holidays"], workspaceRoot);
|
||||||
rmSync(webDistDirPath, { recursive: true, force: true });
|
rmSync(webDistDirPath, { recursive: true, force: true });
|
||||||
|
|
||||||
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
||||||
|
|||||||
+289
-162
@@ -133,7 +133,7 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
|
|||||||
data: {
|
data: {
|
||||||
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
||||||
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
||||||
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)},
|
email: ${JSON.stringify(`e2e.timeline.${suffix}@nexus.dev`)},
|
||||||
chapter: "E2E",
|
chapter: "E2E",
|
||||||
lcrCents: 5000,
|
lcrCents: 5000,
|
||||||
ucrCents: 9000,
|
ucrCents: 9000,
|
||||||
@@ -208,7 +208,7 @@ function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
|
|||||||
data: {
|
data: {
|
||||||
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
|
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
|
||||||
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
|
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
|
||||||
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)},
|
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@nexus.dev`)},
|
||||||
chapter: "E2E",
|
chapter: "E2E",
|
||||||
lcrCents: 5000,
|
lcrCents: 5000,
|
||||||
ucrCents: 9000,
|
ucrCents: 9000,
|
||||||
@@ -341,7 +341,9 @@ function listScenarioAssignments(projectId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listScenarioDemands(projectId: string) {
|
function listScenarioDemands(projectId: string) {
|
||||||
return runDbJson<Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>>(`
|
return runDbJson<
|
||||||
|
Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>
|
||||||
|
>(`
|
||||||
const demands = await prisma.demandRequirement.findMany({
|
const demands = await prisma.demandRequirement.findMany({
|
||||||
where: { projectId: ${JSON.stringify(projectId)} },
|
where: { projectId: ${JSON.stringify(projectId)} },
|
||||||
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
||||||
@@ -448,10 +450,7 @@ async function openAllocationContextMenuAtOffset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openContextMenuAtCenter(
|
async function openContextMenuAtCenter(page: Page, locator: ReturnType<Page["locator"]>) {
|
||||||
page: Page,
|
|
||||||
locator: ReturnType<Page["locator"]>,
|
|
||||||
) {
|
|
||||||
const target = await resolveAllocationContextMenuTarget(locator);
|
const target = await resolveAllocationContextMenuTarget(locator);
|
||||||
const box = await readBoundingBox(target);
|
const box = await readBoundingBox(target);
|
||||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||||
@@ -511,9 +510,7 @@ async function listRenderedAllocationSegments(
|
|||||||
row: ReturnType<Page["locator"]>,
|
row: ReturnType<Page["locator"]>,
|
||||||
allocationId?: string,
|
allocationId?: string,
|
||||||
) {
|
) {
|
||||||
const selector = allocationId
|
const selector = allocationId ? `[data-allocation-id="${allocationId}"]` : "[data-allocation-id]";
|
||||||
? `[data-allocation-id="${allocationId}"]`
|
|
||||||
: "[data-allocation-id]";
|
|
||||||
return row.locator(selector).evaluateAll((elements) =>
|
return row.locator(selector).evaluateAll((elements) =>
|
||||||
elements.map((element) => {
|
elements.map((element) => {
|
||||||
const htmlElement = element as HTMLElement;
|
const htmlElement = element as HTMLElement;
|
||||||
@@ -536,17 +533,13 @@ function escapeRegex(value: string) {
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findVisibleTimelineEntryId(
|
async function findVisibleTimelineEntryId(page: Page, selector: string, minimumWidth = 24) {
|
||||||
page: Page,
|
|
||||||
selector: string,
|
|
||||||
minimumWidth = 24,
|
|
||||||
) {
|
|
||||||
return page.locator(selector).evaluateAll((elements, minimum) => {
|
return page.locator(selector).evaluateAll((elements, minimum) => {
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!(element instanceof HTMLElement)) continue;
|
if (!(element instanceof HTMLElement)) continue;
|
||||||
@@ -600,9 +593,9 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
);
|
);
|
||||||
const stickyHeaderBottom = scrollContainer
|
const stickyHeaderBottom = scrollContainer
|
||||||
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
||||||
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
||||||
const candidates: Array<{
|
const candidates: Array<{
|
||||||
@@ -611,8 +604,11 @@ async function findVisibleAllocationSegmentForResize(
|
|||||||
segmentEnd: string | null;
|
segmentEnd: string | null;
|
||||||
score: number;
|
score: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
|
let fallback: {
|
||||||
null;
|
allocationId: string;
|
||||||
|
segmentStart: string | null;
|
||||||
|
segmentEnd: string | null;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
if (!(element instanceof HTMLElement)) continue;
|
if (!(element instanceof HTMLElement)) continue;
|
||||||
@@ -829,13 +825,20 @@ async function switchToProjectView(page: Page, readySelector?: string) {
|
|||||||
await expect(page.locator(readySelector).first()).toBeVisible();
|
await expect(page.locator(readySelector).first()).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(
|
||||||
const projectRows = await page.getByTestId("timeline-project-resource-row-canvas").count();
|
async () => {
|
||||||
const projectBars = await page.locator("[data-timeline-entry-type='project-bar']").count();
|
const projectRows = await page
|
||||||
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
.getByTestId("timeline-project-resource-row-canvas")
|
||||||
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
.count();
|
||||||
return projectRows + projectBars + demandBars + emptyStates;
|
const projectBars = await page
|
||||||
}, { timeout: 10_000 })
|
.locator("[data-timeline-entry-type='project-bar']")
|
||||||
|
.count();
|
||||||
|
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
||||||
|
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
||||||
|
return projectRows + projectBars + demandBars + emptyStates;
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
.not.toBe(0);
|
.not.toBe(0);
|
||||||
}
|
}
|
||||||
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
|
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
|
||||||
@@ -853,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
|
|||||||
|
|
||||||
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const raw = window.localStorage.getItem("capakraken_prefs");
|
const raw = window.localStorage.getItem("nexus_prefs");
|
||||||
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
"capakraken_prefs",
|
"nexus_prefs",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...parsed,
|
...parsed,
|
||||||
showDemandProjects: true,
|
showDemandProjects: true,
|
||||||
@@ -871,9 +874,9 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"capakraken_prefs",
|
"nexus_prefs",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
hideCompletedProjects: true,
|
hideCompletedProjects: true,
|
||||||
timelineDisplayMode: "strip",
|
timelineDisplayMode: "strip",
|
||||||
@@ -906,22 +909,21 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ page }) => {
|
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const scenario = createTimelineSegmentScenario(suffix);
|
const scenario = createTimelineSegmentScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(
|
await page.goto(`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, {
|
||||||
`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`,
|
waitUntil: "domcontentloaded",
|
||||||
{ waitUntil: "domcontentloaded" },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const projectButton = page.getByRole("button", { name: "Project view" });
|
const projectButton = page.getByRole("button", { name: "Project view" });
|
||||||
const resourceButton = page.getByRole("button", { name: "Resource view" });
|
const resourceButton = page.getByRole("button", { name: "Resource view" });
|
||||||
const resourceRowSelector =
|
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
const projectRowSelector =
|
|
||||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
|
||||||
|
|
||||||
await expect(projectButton).toBeDisabled();
|
await expect(projectButton).toBeDisabled();
|
||||||
await expect(resourceButton).toBeDisabled();
|
await expect(resourceButton).toBeDisabled();
|
||||||
@@ -951,9 +953,9 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
||||||
await page.goto("/allocations");
|
await page.goto("/allocations");
|
||||||
await expect(
|
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
||||||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
|
|
||||||
await page.locator('nav a >> text="Timeline"').first().click();
|
await page.locator('nav a >> text="Timeline"').first().click();
|
||||||
await expect(page).toHaveURL(/\/timeline/);
|
await expect(page).toHaveURL(/\/timeline/);
|
||||||
@@ -1046,7 +1048,10 @@ test.describe("Timeline", () => {
|
|||||||
if (!projectAllocationBox) {
|
if (!projectAllocationBox) {
|
||||||
throw new Error("Expected a project allocation block to be available");
|
throw new Error("Expected a project allocation block to be available");
|
||||||
}
|
}
|
||||||
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
await page.mouse.move(
|
||||||
|
projectAllocationBox.x + projectAllocationBox.width / 2,
|
||||||
|
projectHoverBox.y + 20,
|
||||||
|
);
|
||||||
await expect(heatmapTooltip).toBeVisible();
|
await expect(heatmapTooltip).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -1071,7 +1076,9 @@ test.describe("Timeline", () => {
|
|||||||
.first();
|
.first();
|
||||||
await allocation.click({ button: "right" });
|
await allocation.click({ button: "right" });
|
||||||
|
|
||||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
|
||||||
|
timeout: 2_000,
|
||||||
|
});
|
||||||
const popover = page.getByTestId("timeline-allocation-popover");
|
const popover = page.getByTestId("timeline-allocation-popover");
|
||||||
await expect(popover).toBeVisible();
|
await expect(popover).toBeVisible();
|
||||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||||
@@ -1103,12 +1110,16 @@ test.describe("Timeline", () => {
|
|||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
const row = page
|
||||||
|
.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]')
|
||||||
|
.first();
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const holidayBlock = row.locator(
|
const holidayBlock = row
|
||||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
.locator(
|
||||||
).first();
|
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(holidayBlock).toBeVisible();
|
await expect(holidayBlock).toBeVisible();
|
||||||
|
|
||||||
const rowBox = await row.boundingBox();
|
const rowBox = await row.boundingBox();
|
||||||
@@ -1129,7 +1140,9 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
const holidayTooltip = page
|
const holidayTooltip = page
|
||||||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||||
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
.or(
|
||||||
|
page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }),
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
await expect(holidayTooltip).toBeVisible();
|
await expect(holidayTooltip).toBeVisible();
|
||||||
@@ -1278,9 +1291,7 @@ test.describe("Timeline", () => {
|
|||||||
expect(result.maxGap).toBeLessThan(24);
|
expect(result.maxGap).toBeLessThan(24);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allocation resize shows a live preview before mouseup", async ({
|
test("allocation resize shows a live preview before mouseup", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
@@ -1358,9 +1369,7 @@ test.describe("Timeline", () => {
|
|||||||
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allocation start resize shows a live preview before mouseup", async ({
|
test("allocation start resize shows a live preview before mouseup", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
@@ -1394,18 +1403,17 @@ test.describe("Timeline", () => {
|
|||||||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||||||
waitUntil: "domcontentloaded",
|
waitUntil: "domcontentloaded",
|
||||||
});
|
});
|
||||||
const resourceRowSelector =
|
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
const projectRowSelector =
|
const projectAllocationSelector = `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
||||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
|
||||||
const projectAllocationSelector =
|
|
||||||
`${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
|
||||||
|
|
||||||
await expect(page.locator(resourceRowSelector)).toBeVisible();
|
await expect(page.locator(resourceRowSelector)).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(
|
page
|
||||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
.locator(
|
||||||
).first(),
|
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await switchToProjectView(page, projectRowSelector);
|
await switchToProjectView(page, projectRowSelector);
|
||||||
@@ -1427,19 +1435,22 @@ test.describe("Timeline", () => {
|
|||||||
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
|
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
|
||||||
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
() => {
|
||||||
if (rightResizeAssignments.length !== 1) {
|
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||||
return null;
|
if (rightResizeAssignments.length !== 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [assignment] = rightResizeAssignments;
|
const [assignment] = rightResizeAssignments;
|
||||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment.endDate;
|
return assignment.endDate;
|
||||||
}, { timeout: 15_000 })
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.not.toBe("2026-04-17");
|
.not.toBe("2026-04-17");
|
||||||
expect(rightResizeAssignments).toHaveLength(1);
|
expect(rightResizeAssignments).toHaveLength(1);
|
||||||
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
@@ -1451,19 +1462,22 @@ test.describe("Timeline", () => {
|
|||||||
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
|
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
|
||||||
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
() => {
|
||||||
if (leftResizeAssignments.length !== 1) {
|
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||||
return null;
|
if (leftResizeAssignments.length !== 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [assignment] = leftResizeAssignments;
|
const [assignment] = leftResizeAssignments;
|
||||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assignment.startDate;
|
return assignment.startDate;
|
||||||
}, { timeout: 15_000 })
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.not.toBe("2026-04-06");
|
.not.toBe("2026-04-06");
|
||||||
expect(leftResizeAssignments).toHaveLength(1);
|
expect(leftResizeAssignments).toHaveLength(1);
|
||||||
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||||
@@ -1479,15 +1493,12 @@ test.describe("Timeline", () => {
|
|||||||
const scenario = createTimelineDemandScenario(suffix);
|
const scenario = createTimelineDemandScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(
|
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
||||||
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
waitUntil: "domcontentloaded",
|
||||||
{ waitUntil: "domcontentloaded" },
|
});
|
||||||
);
|
|
||||||
await ensureOpenDemandVisibilityEnabled(page);
|
await ensureOpenDemandVisibilityEnabled(page);
|
||||||
const demandRowSelector =
|
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||||
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||||
const demandSelector =
|
|
||||||
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
|
||||||
|
|
||||||
await switchToProjectView(page, demandRowSelector);
|
await switchToProjectView(page, demandRowSelector);
|
||||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||||
@@ -1505,19 +1516,22 @@ test.describe("Timeline", () => {
|
|||||||
status: string;
|
status: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
() => {
|
||||||
if (rightResizeDemands.length !== 1) {
|
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
||||||
return null;
|
if (rightResizeDemands.length !== 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [demand] = rightResizeDemands;
|
const [demand] = rightResizeDemands;
|
||||||
if (!demand || demand.id !== scenario.demandId) {
|
if (!demand || demand.id !== scenario.demandId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return demand.endDate;
|
return demand.endDate;
|
||||||
}, { timeout: 15_000 })
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.not.toBe("2026-04-16");
|
.not.toBe("2026-04-16");
|
||||||
expect(rightResizeDemands).toHaveLength(1);
|
expect(rightResizeDemands).toHaveLength(1);
|
||||||
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
@@ -1538,19 +1552,22 @@ test.describe("Timeline", () => {
|
|||||||
status: string;
|
status: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
() => {
|
||||||
if (leftResizeDemands.length !== 1) {
|
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
||||||
return null;
|
if (leftResizeDemands.length !== 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const [demand] = leftResizeDemands;
|
const [demand] = leftResizeDemands;
|
||||||
if (!demand || demand.id !== scenario.demandId) {
|
if (!demand || demand.id !== scenario.demandId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return demand.startDate;
|
return demand.startDate;
|
||||||
}, { timeout: 15_000 })
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.not.toBe("2026-04-07");
|
.not.toBe("2026-04-07");
|
||||||
expect(leftResizeDemands).toHaveLength(1);
|
expect(leftResizeDemands).toHaveLength(1);
|
||||||
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||||
@@ -1630,7 +1647,11 @@ test.describe("Timeline", () => {
|
|||||||
);
|
);
|
||||||
await expect(resizedSegment).toBeVisible();
|
await expect(resizedSegment).toBeVisible();
|
||||||
|
|
||||||
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
|
await dragLocatorBy(
|
||||||
|
page,
|
||||||
|
resizedSegment.locator('[data-allocation-interaction="body"]'),
|
||||||
|
-dayWidth,
|
||||||
|
);
|
||||||
await releaseMouse(page);
|
await releaseMouse(page);
|
||||||
|
|
||||||
await waitForScenarioAssignments(scenario.projectId, [
|
await waitForScenarioAssignments(scenario.projectId, [
|
||||||
@@ -1674,9 +1695,21 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
const leftSplit = row
|
||||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
.locator(
|
||||||
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const rightSplit = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const nextWeekSegment = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(nextWeekSegment).toBeVisible();
|
await expect(nextWeekSegment).toBeVisible();
|
||||||
@@ -1704,22 +1737,42 @@ test.describe("Timeline", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -1769,9 +1822,21 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
const leftSplit = row
|
||||||
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first();
|
.locator(
|
||||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const fridayBridge = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const mondaySegment = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(fridayBridge).toBeVisible();
|
await expect(fridayBridge).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1797,13 +1862,25 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -1850,9 +1927,21 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
const leftSplit = row
|
||||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
.locator(
|
||||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const rightSplit = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const mondaySegment = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1870,8 +1959,16 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
const resizedRightSplit = row
|
||||||
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth);
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
await dragLocatorBy(
|
||||||
|
page,
|
||||||
|
resizedRightSplit.locator('[data-allocation-handle="end"]'),
|
||||||
|
-dayWidth,
|
||||||
|
);
|
||||||
await releaseMouse(page);
|
await releaseMouse(page);
|
||||||
|
|
||||||
await waitForScenarioAssignments(scenario.projectId, [
|
await waitForScenarioAssignments(scenario.projectId, [
|
||||||
@@ -1883,9 +1980,11 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
const mondaySegmentAfterReload = row.locator(
|
const mondaySegmentAfterReload = row
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
.locator(
|
||||||
).first();
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(mondaySegmentAfterReload).toBeVisible();
|
await expect(mondaySegmentAfterReload).toBeVisible();
|
||||||
|
|
||||||
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||||||
@@ -1951,9 +2050,21 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
const leftSplit = row
|
||||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
.locator(
|
||||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const rightSplit = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
const mondaySegment = row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(leftSplit).toBeVisible();
|
await expect(leftSplit).toBeVisible();
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1968,7 +2079,11 @@ test.describe("Timeline", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(rightSplit).toBeVisible();
|
await expect(rightSplit).toBeVisible();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
@@ -1976,13 +2091,25 @@ test.describe("Timeline", () => {
|
|||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
row
|
||||||
|
.locator(
|
||||||
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||||
@@ -2029,13 +2156,14 @@ test.describe("Timeline", () => {
|
|||||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mondaySegment = resourceRow.locator(
|
const mondaySegment = resourceRow
|
||||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
.locator(
|
||||||
).first();
|
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await expect(mondaySegment).toBeVisible();
|
await expect(mondaySegment).toBeVisible();
|
||||||
|
|
||||||
const projectRowSelector =
|
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
|
||||||
await switchToProjectView(page, projectRowSelector);
|
await switchToProjectView(page, projectRowSelector);
|
||||||
|
|
||||||
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
||||||
@@ -2072,7 +2200,9 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||||
|
|
||||||
const projectAllocationAfterReload = page
|
const projectAllocationAfterReload = page
|
||||||
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
|
.locator(
|
||||||
|
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
await expect(projectAllocationAfterReload).toBeVisible();
|
await expect(projectAllocationAfterReload).toBeVisible();
|
||||||
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
||||||
@@ -2093,15 +2223,12 @@ test.describe("Timeline", () => {
|
|||||||
const scenario = createTimelineDemandScenario(suffix);
|
const scenario = createTimelineDemandScenario(suffix);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(
|
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
||||||
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
waitUntil: "domcontentloaded",
|
||||||
{ waitUntil: "domcontentloaded" },
|
});
|
||||||
);
|
|
||||||
await ensureOpenDemandVisibilityEnabled(page);
|
await ensureOpenDemandVisibilityEnabled(page);
|
||||||
const demandRowSelector =
|
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||||
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||||
const demandSelector =
|
|
||||||
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
|
||||||
|
|
||||||
await switchToProjectView(page, demandRowSelector);
|
await switchToProjectView(page, demandRowSelector);
|
||||||
await expect(page.locator(demandSelector)).toBeVisible();
|
await expect(page.locator(demandSelector)).toBeVisible();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
|||||||
|
|
||||||
async function signInAsAdmin(page: Page) {
|
async function signInAsAdmin(page: Page) {
|
||||||
await page.goto("/auth/signin");
|
await page.goto("/auth/signin");
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||||
await page.fill('input[type="password"]', "admin123");
|
await page.fill('input[type="password"]', "admin123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
@@ -27,9 +27,9 @@ test.describe("Vacations", () => {
|
|||||||
|
|
||||||
test("request vacation button is visible", async ({ page }) => {
|
test("request vacation button is visible", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await expect(
|
await expect(page.locator("button", { hasText: /Request Vacation/i })).toBeVisible({
|
||||||
page.locator("button", { hasText: /Request Vacation/i }),
|
timeout: 10000,
|
||||||
).toBeVisible({ timeout: 10000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request vacation is blocked without linked resource", async ({ page }) => {
|
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||||
@@ -37,7 +37,9 @@ test.describe("Vacations", () => {
|
|||||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||||
await expect(reqBtn).toBeDisabled();
|
await expect(reqBtn).toBeDisabled();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
page.getByText(
|
||||||
|
"Your account is not linked to a resource. Please contact an administrator.",
|
||||||
|
),
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,11 +59,18 @@ test.describe("Vacations", () => {
|
|||||||
|
|
||||||
test("team calendar tab renders", async ({ page }) => {
|
test("team calendar tab renders", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.locator("button", { hasText: "Team Calendar" }).or(page.locator("text=Team Calendar")).first().click();
|
await page
|
||||||
|
.locator("button", { hasText: "Team Calendar" })
|
||||||
|
.or(page.locator("text=Team Calendar"))
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
// Calendar view should appear
|
// Calendar view should appear
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("table").or(page.locator("[data-calendar]")).or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
page
|
||||||
|
.locator("table")
|
||||||
|
.or(page.locator("[data-calendar]"))
|
||||||
|
.or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,11 +84,15 @@ test.describe("Vacations", () => {
|
|||||||
await expect(filters.nth(2)).toHaveValue("");
|
await expect(filters.nth(2)).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
test("vacation request preview excludes regional public holidays from deducted days", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
await page.getByRole("button", { name: /request vacation/i }).click();
|
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||||
|
|
||||||
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
await expect(
|
||||||
|
page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i }),
|
||||||
|
).toHaveCount(0);
|
||||||
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||||
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||||
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||||
@@ -89,9 +102,13 @@ test.describe("Vacations", () => {
|
|||||||
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||||
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||||
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText(
|
||||||
|
"2026-01-06",
|
||||||
|
);
|
||||||
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText(
|
||||||
|
"Holiday Calendar",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import nextjsConfig from "@capakraken/eslint-config/nextjs";
|
import nextjsConfig from "@nexus/eslint-config/nextjs";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ const nextConfig: NextConfig = {
|
|||||||
"recharts",
|
"recharts",
|
||||||
"date-fns",
|
"date-fns",
|
||||||
"framer-motion",
|
"framer-motion",
|
||||||
"@capakraken/shared",
|
"@nexus/shared",
|
||||||
"@react-pdf/renderer",
|
"@react-pdf/renderer",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@capakraken/api",
|
"@nexus/api",
|
||||||
"@capakraken/db",
|
"@nexus/db",
|
||||||
"@capakraken/engine",
|
"@nexus/engine",
|
||||||
"@capakraken/shared",
|
"@nexus/shared",
|
||||||
"@capakraken/staffing",
|
"@nexus/staffing",
|
||||||
],
|
],
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@capakraken/web",
|
"name": "@nexus/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
|
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capakraken/api": "workspace:*",
|
"@nexus/api": "workspace:*",
|
||||||
"@capakraken/application": "workspace:*",
|
"@nexus/application": "workspace:*",
|
||||||
"@capakraken/db": "workspace:*",
|
"@nexus/db": "workspace:*",
|
||||||
"@capakraken/engine": "workspace:*",
|
"@nexus/engine": "workspace:*",
|
||||||
"@capakraken/shared": "workspace:*",
|
"@nexus/shared": "workspace:*",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"dompurify": "^3.4.0",
|
"dompurify": "^3.4.0",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.16",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "^16.2.3",
|
"@next/bundle-analyzer": "^16.2.3",
|
||||||
"@axe-core/playwright": "^4.11.1",
|
"@axe-core/playwright": "^4.11.1",
|
||||||
"@capakraken/eslint-config": "workspace:*",
|
"@nexus/eslint-config": "workspace:*",
|
||||||
"@capakraken/tsconfig": "workspace:*",
|
"@nexus/tsconfig": "workspace:*",
|
||||||
"@playwright/test": "^1.49.1",
|
"@playwright/test": "^1.49.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* dev server at localhost:3100 and exercises real dev-DB data.
|
* dev server at localhost:3100 and exercises real dev-DB data.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts
|
* pnpm --filter @nexus/web exec playwright test --config playwright.dev.config.ts
|
||||||
*
|
*
|
||||||
* Prerequisites:
|
* Prerequisites:
|
||||||
* - Dev server running: pnpm run dev (or docker compose up)
|
* - Dev server running: pnpm run dev (or docker compose up)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "CapaKraken — Resource & Capacity Planning",
|
"name": "Nexus — Resource & Capacity Planning",
|
||||||
"short_name": "CapaKraken",
|
"short_name": "Nexus",
|
||||||
"description": "Resource planning and project staffing for 3D production",
|
"description": "Resource planning and project staffing for 3D production",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
const CACHE_NAME = "capakraken-v2";
|
const CACHE_NAME = "nexus-v2";
|
||||||
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
||||||
|
|
||||||
// Offline fallback page (simple inline HTML)
|
// Offline fallback page (simple inline HTML)
|
||||||
@@ -9,7 +9,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>CapaKraken - Offline</title>
|
<title>Nexus - Offline</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -31,7 +31,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>You are offline</h1>
|
<h1>You are offline</h1>
|
||||||
<p>CapaKraken requires an internet connection. Please check your network and try again.</p>
|
<p>Nexus requires an internet connection. Please check your network and try again.</p>
|
||||||
<button onclick="location.reload()">Retry</button>
|
<button onclick="location.reload()">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
|
|||||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||||
|
|
||||||
export const metadata = { title: "Vacation Management — CapaKraken" };
|
export const metadata = { title: "Vacation Management — Nexus" };
|
||||||
|
|
||||||
export default function AdminVacationsPage() {
|
export default function AdminVacationsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -10,15 +10,19 @@ export default function AdminVacationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
|
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
|
||||||
|
Fallback-Importe.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Holiday Calendars
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
|
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung,
|
||||||
|
Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<HolidayCalendarEditor />
|
<HolidayCalendarEditor />
|
||||||
@@ -26,9 +30,12 @@ export default function AdminVacationsPage() {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Legacy Batch Import
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die
|
||||||
|
Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PublicHolidayBatch />
|
<PublicHolidayBatch />
|
||||||
@@ -36,9 +43,12 @@ export default function AdminVacationsPage() {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Entitlements
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
|
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
|
||||||
|
aufgeloest wurden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<EntitlementManager />
|
<EntitlementManager />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared";
|
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
@@ -122,7 +122,8 @@ function EstimateDetailPanel({
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
||||||
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
Estimate detail{" "}
|
||||||
|
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{estimate.name}
|
{estimate.name}
|
||||||
@@ -206,7 +207,8 @@ function EstimateDetailPanel({
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
Scope items{" "}
|
||||||
|
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +241,8 @@ function EstimateDetailPanel({
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
Demand lines{" "}
|
||||||
|
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,13 +348,19 @@ function EstimateCard({
|
|||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Opportunity{" "}
|
||||||
|
<InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." />
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||||
{estimate.opportunityId ?? "Not set"}
|
{estimate.opportunityId ?? "Not set"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
|
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Updated{" "}
|
||||||
|
<InfoTooltip content="When this estimate or any of its versions was last modified." />
|
||||||
|
</p>
|
||||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||||
{formatDateLong(estimate.updatedAt)}
|
{formatDateLong(estimate.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
@@ -466,7 +475,7 @@ export function EstimatesClient() {
|
|||||||
No estimates yet
|
No estimates yet
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||||
Start with the wizard to create a connected estimate from CapaKraken data.
|
Start with the wizard to create a connected estimate from Nexus data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "CapaKraken — Mobile Summary",
|
title: "Nexus — Mobile Summary",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MobilePage() {
|
export default function MobilePage() {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
|||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||||
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
|
import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
|
||||||
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|||||||
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Resource, SkillEntry } from "@capakraken/shared";
|
import type { Resource, SkillEntry } from "@nexus/shared";
|
||||||
import { RESOURCE_COLUMNS } from "@capakraken/shared";
|
import { RESOURCE_COLUMNS } from "@nexus/shared";
|
||||||
import { BlueprintTarget, ResourceType } from "@capakraken/shared";
|
import { BlueprintTarget, ResourceType } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { formatMoney } from "~/lib/format.js";
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
||||||
@@ -945,7 +945,7 @@ export function ResourcesClient() {
|
|||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onSort={toggle}
|
onSort={toggle}
|
||||||
tooltip="Unique employee identifier used across all CapaKraken records."
|
tooltip="Unique employee identifier used across all Nexus records."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "displayName":
|
case "displayName":
|
||||||
|
|||||||
@@ -2,24 +2,22 @@ import type { Metadata } from "next";
|
|||||||
import { createCaller } from "~/server/trpc.js";
|
import { createCaller } from "~/server/trpc.js";
|
||||||
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
||||||
|
|
||||||
export async function generateMetadata(
|
export async function generateMetadata({
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
params,
|
||||||
): Promise<Metadata> {
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const trpc = await createCaller();
|
const trpc = await createCaller();
|
||||||
const resource = await trpc.resource.getById({ id });
|
const resource = await trpc.resource.getById({ id });
|
||||||
return { title: `${resource.displayName} — Resources | CapaKraken` };
|
return { title: `${resource.displayName} — Resources | Nexus` };
|
||||||
} catch {
|
} catch {
|
||||||
return { title: "Resource — CapaKraken" };
|
return { title: "Resource — Nexus" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ResourceDetailPage({
|
export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <ResourceDetail resourceId={id} />;
|
return <ResourceDetail resourceId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Resource } from "@capakraken/shared";
|
import type { Resource } from "@nexus/shared";
|
||||||
import { ResourceType } from "@capakraken/shared";
|
import { ResourceType } from "@nexus/shared";
|
||||||
|
|
||||||
export type ModalState =
|
export type ModalState =
|
||||||
| { type: "closed" }
|
| { type: "closed" }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
||||||
|
|
||||||
export const metadata = { title: "My Vacations — CapaKraken" };
|
export const metadata = { title: "My Vacations — Nexus" };
|
||||||
|
|
||||||
export default function MyVacationsPage() {
|
export default function MyVacationsPage() {
|
||||||
return <MyVacationsClient />;
|
return <MyVacationsClient />;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
|
|
||||||
/** Window over which auth events are analysed. */
|
/** Window over which auth events are analysed. */
|
||||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
|
|||||||
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
|
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
|
||||||
const userFindManyMock = vi.hoisted(() => vi.fn());
|
const userFindManyMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("@capakraken/db", () => ({
|
vi.mock("@nexus/db", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
auditLog: { findMany: auditLogFindManyMock },
|
auditLog: { findMany: auditLogFindManyMock },
|
||||||
user: { findMany: userFindManyMock },
|
user: { findMany: userFindManyMock },
|
||||||
@@ -27,11 +27,11 @@ vi.mock("@capakraken/db", () => ({
|
|||||||
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
|
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
|
||||||
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
|
||||||
vi.mock("@capakraken/api", () => ({
|
vi.mock("@nexus/api", () => ({
|
||||||
createNotificationsForUsers: createNotificationsMock,
|
createNotificationsForUsers: createNotificationsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@capakraken/api/lib/logger", () => ({
|
vi.mock("@nexus/api/lib/logger", () => ({
|
||||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
|
|||||||
const { GET } = await importRoute();
|
const { GET } = await importRoute();
|
||||||
const res = await GET(makeRequest());
|
const res = await GET(makeRequest());
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
}, 15_000); // next/server cold-import can take >5s on the act runner
|
||||||
|
|
||||||
it("proceeds when verifyCronSecret returns null (allowed)", async () => {
|
it("proceeds when verifyCronSecret returns null (allowed)", async () => {
|
||||||
verifyCronSecretMock.mockReturnValue(null);
|
verifyCronSecretMock.mockReturnValue(null);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { createNotificationsForUsers } from "@capakraken/api";
|
import { createNotificationsForUsers } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
import { detectAuthAnomalies } from "./detect.js";
|
import { detectAuthAnomalies } from "./detect.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { checkChargeabilityAlerts } from "@capakraken/api";
|
import { checkChargeabilityAlerts } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { checkPendingEstimateReminders } from "@capakraken/api";
|
import { checkPendingEstimateReminders } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { createNotificationsForUsers } from "@capakraken/api";
|
import { createNotificationsForUsers } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { autoImportPublicHolidays } from "@capakraken/api";
|
import { autoImportPublicHolidays } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
|
|||||||
skippedExisting: result.skippedExisting,
|
skippedExisting: result.skippedExisting,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
|
logger.error(
|
||||||
return NextResponse.json(
|
{ error, route: "/api/cron/public-holidays", year },
|
||||||
{ ok: false, error: "Internal error" },
|
"Public holiday import cron failed",
|
||||||
{ status: 500 },
|
|
||||||
);
|
);
|
||||||
|
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { createNotificationsForUsers } from "@capakraken/api";
|
import { createNotificationsForUsers } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { sendWeeklyDigest } from "@capakraken/api";
|
import { sendWeeklyDigest } from "@nexus/api";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@nexus/api/lib/logger";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -30,8 +30,14 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
resolve(data.toString().includes("PONG") ? "ok" : "error");
|
resolve(data.toString().includes("PONG") ? "ok" : "error");
|
||||||
});
|
});
|
||||||
socket.on("timeout", () => { socket.destroy(); resolve("error"); });
|
socket.on("timeout", () => {
|
||||||
socket.on("error", () => { socket.destroy(); resolve("error"); });
|
socket.destroy();
|
||||||
|
resolve("error");
|
||||||
|
});
|
||||||
|
socket.on("error", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve("error");
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
resolve("error");
|
resolve("error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@capakraken/api/sse", () => ({
|
vi.mock("@nexus/api/sse", () => ({
|
||||||
eventBus: { subscriberCount: 0 },
|
eventBus: { subscriberCount: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
|
|||||||
const response = await GET(request);
|
const response = await GET(request);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown };
|
const body = (await response.json()) as { timestamp: string; uptime: unknown; memory: unknown };
|
||||||
expect(typeof body.timestamp).toBe("string");
|
expect(typeof body.timestamp).toBe("string");
|
||||||
expect(body.uptime).toBeDefined();
|
expect(body.uptime).toBeDefined();
|
||||||
expect(body.memory).toBeDefined();
|
expect(body.memory).toBeDefined();
|
||||||
@@ -81,7 +81,11 @@ describe("GET /api/perf — security hardening", () => {
|
|||||||
const response = await GET(request);
|
const response = await GET(request);
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
|
|
||||||
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
|
const body = (await response.json()) as {
|
||||||
|
error?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
memory?: unknown;
|
||||||
|
};
|
||||||
expect(body.timestamp).toBeUndefined();
|
expect(body.timestamp).toBeUndefined();
|
||||||
expect(body.memory).toBeUndefined();
|
expect(body.memory).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { eventBus } from "@capakraken/api/sse";
|
import { eventBus } from "@nexus/api/sse";
|
||||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { createConnection } from "net";
|
import { createConnection } from "net";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight Redis PING check using a raw TCP socket.
|
* Lightweight Redis PING check using a raw TCP socket.
|
||||||
* Avoids importing ioredis (which is only a dependency of @capakraken/api).
|
* Avoids importing ioredis (which is only a dependency of @nexus/api).
|
||||||
*/
|
*/
|
||||||
async function checkRedis(): Promise<"ok" | "error"> {
|
async function checkRedis(): Promise<"ok" | "error"> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -58,10 +58,7 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [postgres, redis] = await Promise.all([
|
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
|
||||||
checkPostgres(),
|
|
||||||
checkRedis(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allHealthy = postgres === "ok" && redis === "ok";
|
const allHealthy = postgres === "ok" && redis === "ok";
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
|
|||||||
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
|
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
|
||||||
|
|
||||||
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
|
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
|
||||||
vi.mock("@capakraken/db", () => ({
|
vi.mock("@nexus/db", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
@@ -21,11 +21,11 @@ vi.mock("@capakraken/db", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@capakraken/application", () => ({
|
vi.mock("@nexus/application", () => ({
|
||||||
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
|
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@capakraken/api", () => ({
|
vi.mock("@nexus/api", () => ({
|
||||||
anonymizeResource: vi.fn((r: unknown) => r),
|
anonymizeResource: vi.fn((r: unknown) => r),
|
||||||
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { renderToBuffer } from "@react-pdf/renderer";
|
import { renderToBuffer } from "@react-pdf/renderer";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
import { z } from "zod";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
import { buildSplitAllocationReadModel } from "@nexus/application";
|
||||||
import { prisma } from "@capakraken/db";
|
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
|
||||||
import type { AllocationLike } from "@capakraken/shared";
|
import { prisma } from "@nexus/db";
|
||||||
|
import type { AllocationLike } from "@nexus/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
||||||
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
||||||
|
|
||||||
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
|
const ALLOWED_ROLES = new Set(["ADMIN", "MANAGER", "CONTROLLER"]);
|
||||||
|
|
||||||
|
// Reject fantasy dates from clients — years outside [2000, 2100] are almost
|
||||||
|
// certainly malformed input and would generate nonsensical SQL range scans.
|
||||||
|
const DATE_MIN = new Date("2000-01-01T00:00:00.000Z");
|
||||||
|
const DATE_MAX = new Date("2100-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
const queryParamsSchema = z.object({
|
||||||
|
startDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||||
|
endDate: z.coerce.date().min(DATE_MIN).max(DATE_MAX).optional(),
|
||||||
|
format: z.enum(["pdf", "xlsx"]).default("pdf"),
|
||||||
|
});
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -23,9 +35,20 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
|
const parsed = queryParamsSchema.safeParse({
|
||||||
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
startDate: searchParams.get("startDate") ?? undefined,
|
||||||
const format = searchParams.get("format") ?? "pdf";
|
endDate: searchParams.get("endDate") ?? undefined,
|
||||||
|
format: searchParams.get("format") ?? undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new NextResponse("Invalid query parameters", { status: 400 });
|
||||||
|
}
|
||||||
|
const startDate = parsed.data.startDate ?? new Date();
|
||||||
|
const endDate = parsed.data.endDate ?? new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
if (endDate < startDate) {
|
||||||
|
return new NextResponse("endDate must be >= startDate", { status: 400 });
|
||||||
|
}
|
||||||
|
const format = parsed.data.format;
|
||||||
|
|
||||||
const [demandRequirements, assignments] = await Promise.all([
|
const [demandRequirements, assignments] = await Promise.all([
|
||||||
prisma.demandRequirement.findMany({
|
prisma.demandRequirement.findMany({
|
||||||
@@ -62,21 +85,25 @@ export async function GET(request: Request) {
|
|||||||
const assignmentRows = allocationView.assignments.slice(0, 500);
|
const assignmentRows = allocationView.assignments.slice(0, 500);
|
||||||
const directory = await getAnonymizationDirectory(prisma);
|
const directory = await getAnonymizationDirectory(prisma);
|
||||||
|
|
||||||
const rows = assignmentRows.map((a: AllocationLike & {
|
const rows = assignmentRows.map(
|
||||||
resource?: { id: string; displayName?: string | null } | null;
|
(
|
||||||
project?: { shortCode: string; name: string } | null;
|
a: AllocationLike & {
|
||||||
}) => {
|
resource?: { id: string; displayName?: string | null } | null;
|
||||||
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
project?: { shortCode: string; name: string } | null;
|
||||||
return {
|
},
|
||||||
resourceName: resource?.displayName ?? "Unknown",
|
) => {
|
||||||
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
const resource = a.resource ? anonymizeResource(a.resource, directory) : null;
|
||||||
role: a.role ?? "",
|
return {
|
||||||
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
resourceName: resource?.displayName ?? "Unknown",
|
||||||
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
||||||
hoursPerDay: a.hoursPerDay,
|
role: a.role ?? "",
|
||||||
dailyCostCents: a.dailyCostCents,
|
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
||||||
};
|
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
||||||
});
|
hoursPerDay: a.hoursPerDay,
|
||||||
|
dailyCostCents: a.dailyCostCents,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { loadRoleDefaults } from "@capakraken/api";
|
import { loadRoleDefaults } from "@nexus/api";
|
||||||
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
|
||||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import type { SystemRole } from "@capakraken/shared";
|
import type { SystemRole } from "@nexus/shared";
|
||||||
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
|
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
// Bounded connection tracking: a single user opening 100 tabs should not be
|
||||||
|
// able to pin 100 persistent subscriptions on this node.
|
||||||
|
const MAX_SSE_CONNECTIONS_PER_USER = 8;
|
||||||
|
const sseConnectionsByUser = new Map<string, number>();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
// Start lazily on the first real SSE request so builds/import-time evaluation
|
// Start lazily on the first real SSE request so builds/import-time evaluation
|
||||||
// never attempt reminder processing against a live database.
|
// never attempt reminder processing against a live database.
|
||||||
@@ -43,6 +48,24 @@ export async function GET() {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentCount = sseConnectionsByUser.get(dbUser.id) ?? 0;
|
||||||
|
if (currentCount >= MAX_SSE_CONNECTIONS_PER_USER) {
|
||||||
|
return new Response("Too many SSE connections", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "Retry-After": "30" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sseConnectionsByUser.set(dbUser.id, currentCount + 1);
|
||||||
|
|
||||||
|
const releaseSlot = () => {
|
||||||
|
const next = (sseConnectionsByUser.get(dbUser.id) ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
sseConnectionsByUser.delete(dbUser.id);
|
||||||
|
} else {
|
||||||
|
sseConnectionsByUser.set(dbUser.id, next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const roleDefaults = await loadRoleDefaults();
|
const roleDefaults = await loadRoleDefaults();
|
||||||
const subscription = deriveUserSseSubscription(
|
const subscription = deriveUserSseSubscription(
|
||||||
{
|
{
|
||||||
@@ -85,6 +108,7 @@ export async function GET() {
|
|||||||
} catch {
|
} catch {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
releaseSlot();
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
@@ -92,8 +116,12 @@ export async function GET() {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
releaseSlot();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cancel() {
|
||||||
|
releaseSlot();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api";
|
import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
|
||||||
import { appRouter } from "@capakraken/api/router";
|
import { appRouter } from "@nexus/api/router";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
@@ -17,6 +17,11 @@ function extractClientIp(req: NextRequest): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard cap on tRPC request body size to prevent memory/CPU amplification from
|
||||||
|
// a single oversized payload. Stream uploads (files, reports) don't go through
|
||||||
|
// tRPC. 2 MiB is comfortably above any legitimate tRPC batch call.
|
||||||
|
const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
// Throttle lastActiveAt updates: max once per 60s per user
|
// Throttle lastActiveAt updates: max once per 60s per user
|
||||||
const lastActiveCache = new Map<string, number>();
|
const lastActiveCache = new Map<string, number>();
|
||||||
const ACTIVITY_THROTTLE_MS = 60_000;
|
const ACTIVITY_THROTTLE_MS = 60_000;
|
||||||
@@ -37,6 +42,23 @@ function trackActivity(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (req: NextRequest) => {
|
const handler = async (req: NextRequest) => {
|
||||||
|
// Reject oversized bodies before we touch auth, DB, or the router. A tRPC
|
||||||
|
// mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is
|
||||||
|
// advisory — also guard against chunked requests below via length check
|
||||||
|
// on the cloned body.
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
const declaredLength = req.headers.get("content-length");
|
||||||
|
if (declaredLength) {
|
||||||
|
const parsed = Number(declaredLength);
|
||||||
|
if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) {
|
||||||
|
return new Response(JSON.stringify({ error: "Request body too large" }), {
|
||||||
|
status: 413,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
// Validate active session registry on every authenticated request.
|
// Validate active session registry on every authenticated request.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use, useState } from "react";
|
import { use, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ export default function SignInPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [totp, setTotp] = useState("");
|
const [totp, setTotp] = useState("");
|
||||||
|
const [backupCode, setBackupCode] = useState("");
|
||||||
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [mfaRequired, setMfaRequired] = useState(false);
|
const [mfaRequired, setMfaRequired] = useState(false);
|
||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
const totpInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const backupCodeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,7 +26,8 @@ export default function SignInPage() {
|
|||||||
const result = await signIn("credentials", {
|
const result = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
...(mfaRequired ? { totp } : {}),
|
...(mfaRequired && !useBackupCode ? { totp } : {}),
|
||||||
|
...(mfaRequired && useBackupCode ? { backupCode } : {}),
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +51,13 @@ export default function SignInPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === "INVALID_TOTP") {
|
if (code === "INVALID_TOTP") {
|
||||||
setError("Invalid verification code. Please try again.");
|
setError(
|
||||||
|
useBackupCode
|
||||||
|
? "Invalid backup code. Please try again."
|
||||||
|
: "Invalid verification code. Please try again.",
|
||||||
|
);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -57,6 +66,8 @@ export default function SignInPage() {
|
|||||||
if (mfaRequired) {
|
if (mfaRequired) {
|
||||||
setMfaRequired(false);
|
setMfaRequired(false);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setUseBackupCode(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Full-page navigation instead of router.push to guarantee a fresh
|
// Full-page navigation instead of router.push to guarantee a fresh
|
||||||
@@ -76,6 +87,8 @@ export default function SignInPage() {
|
|||||||
function handleBackToLogin() {
|
function handleBackToLogin() {
|
||||||
setMfaRequired(false);
|
setMfaRequired(false);
|
||||||
setTotp("");
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setUseBackupCode(false);
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +98,7 @@ export default function SignInPage() {
|
|||||||
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
||||||
CapaKraken Control Center
|
Nexus Control Center
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
||||||
Resource planning that stays readable under pressure.
|
Resource planning that stays readable under pressure.
|
||||||
@@ -124,7 +137,7 @@ export default function SignInPage() {
|
|||||||
Welcome Back
|
Welcome Back
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
|
{mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{mfaRequired
|
{mfaRequired
|
||||||
@@ -183,7 +196,7 @@ export default function SignInPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mfaRequired && (
|
{mfaRequired && !useBackupCode && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="totp" className="app-label">
|
<label htmlFor="totp" className="app-label">
|
||||||
Verification Code
|
Verification Code
|
||||||
@@ -209,22 +222,69 @@ export default function SignInPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mfaRequired && useBackupCode && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="backup-code" className="app-label">
|
||||||
|
Backup Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={backupCodeInputRef}
|
||||||
|
id="backup-code"
|
||||||
|
type="text"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={16}
|
||||||
|
value={backupCode}
|
||||||
|
onChange={(e) => setBackupCode(e.target.value.toUpperCase().slice(0, 16))}
|
||||||
|
className="app-input text-center text-xl font-mono tracking-[0.2em] uppercase"
|
||||||
|
placeholder="XXXXX-XXXXX"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Each backup code works once. You'll need to regenerate your codes after using
|
||||||
|
one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || (mfaRequired && totp.length !== 6)}
|
disabled={
|
||||||
|
loading ||
|
||||||
|
(mfaRequired && !useBackupCode && totp.length !== 6) ||
|
||||||
|
(mfaRequired && useBackupCode && backupCode.replace(/[\s-]/g, "").length < 8)
|
||||||
|
}
|
||||||
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
|
className="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-colors hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
{loading ? "Signing in..." : mfaRequired ? "Verify" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{mfaRequired && (
|
{mfaRequired && (
|
||||||
<button
|
<div className="flex flex-col gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={handleBackToLogin}
|
type="button"
|
||||||
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
onClick={() => {
|
||||||
>
|
setUseBackupCode((v) => !v);
|
||||||
Back to login
|
setError("");
|
||||||
</button>
|
setTotp("");
|
||||||
|
setBackupCode("");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (useBackupCode) totpInputRef.current?.focus();
|
||||||
|
else backupCodeInputRef.current?.focus();
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
className="w-full text-center text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
{useBackupCode ? "Use authenticator code instead" : "Use a backup code instead"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBackToLogin}
|
||||||
|
className="w-full text-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, use } from "react";
|
import { useState, use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
@@ -91,7 +91,7 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
|
You have been invited as <strong>{invite.role}</strong> to Nexus. Set a password to
|
||||||
activate your account (<span className="font-medium">{invite.email}</span>).
|
activate your account (<span className="font-medium">{invite.email}</span>).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+51
-10
@@ -19,8 +19,8 @@ const displayFont = Manrope({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"),
|
metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
|
||||||
title: "CapaKraken — Resource & Capacity Planning",
|
title: "Nexus — Resource & Capacity Planning",
|
||||||
description: "Interactive resource planning and project staffing tool",
|
description: "Interactive resource planning and project staffing tool",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
icons: {
|
icons: {
|
||||||
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
|
|||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "CapaKraken",
|
title: "Nexus",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "CapaKraken — Resource & Capacity Planning",
|
title: "Nexus — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }],
|
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "CapaKraken — Resource & Capacity Planning",
|
title: "Nexus — Resource & Capacity Planning",
|
||||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||||
images: ["/og-image.png"],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
@@ -60,15 +60,56 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: `
|
<script
|
||||||
|
nonce={nonce}
|
||||||
|
suppressHydrationWarning
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
try {
|
try {
|
||||||
var p = JSON.parse(localStorage.getItem('capakraken_theme') || '{}');
|
if (!localStorage.getItem('nexus_migrated_v1')) {
|
||||||
|
var underscoreKeys = ['theme','sidebar_collapsed','mfa_prompt_snoozed_until','prefs','pwa_dismiss'];
|
||||||
|
underscoreKeys.forEach(function(k){
|
||||||
|
var oldK = 'capakraken_' + k, newK = 'nexus_' + k;
|
||||||
|
var v = localStorage.getItem(oldK);
|
||||||
|
if (v !== null && localStorage.getItem(newK) === null) localStorage.setItem(newK, v);
|
||||||
|
localStorage.removeItem(oldK);
|
||||||
|
});
|
||||||
|
var dashKeys = [];
|
||||||
|
for (var i = 0; i < localStorage.length; i++) {
|
||||||
|
var lk = localStorage.key(i);
|
||||||
|
if (lk && lk.indexOf('capakraken_dashboard_v1_') === 0) dashKeys.push(lk);
|
||||||
|
}
|
||||||
|
dashKeys.forEach(function(lk){
|
||||||
|
var newLk = 'nexus_' + lk.substring('capakraken_'.length);
|
||||||
|
var v = localStorage.getItem(lk);
|
||||||
|
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
||||||
|
localStorage.removeItem(lk);
|
||||||
|
});
|
||||||
|
['capakraken-chat-messages','capakraken-chat-conversation-id'].forEach(function(lk){
|
||||||
|
var newLk = 'nexus-' + lk.substring('capakraken-'.length);
|
||||||
|
var v = localStorage.getItem(lk);
|
||||||
|
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
||||||
|
localStorage.removeItem(lk);
|
||||||
|
});
|
||||||
|
var av = localStorage.getItem('capakraken:allocations:viewMode');
|
||||||
|
if (av !== null && localStorage.getItem('nexus:allocations:viewMode') === null) {
|
||||||
|
localStorage.setItem('nexus:allocations:viewMode', av);
|
||||||
|
}
|
||||||
|
localStorage.removeItem('capakraken:allocations:viewMode');
|
||||||
|
localStorage.setItem('nexus_migrated_v1', '1');
|
||||||
|
if (typeof caches !== 'undefined') caches.delete('capakraken-v2');
|
||||||
|
}
|
||||||
|
var p = JSON.parse(localStorage.getItem('nexus_theme') || '{}');
|
||||||
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
||||||
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
`}} />
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
<body
|
||||||
|
className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}
|
||||||
|
>
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||||
import { createFirstAdmin } from "./actions.js";
|
import { createFirstAdmin } from "./actions.js";
|
||||||
|
|
||||||
export function SetupClient() {
|
export function SetupClient() {
|
||||||
@@ -76,7 +76,7 @@ export function SetupClient() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Create the initial administrator account for CapaKraken.
|
Create the initial administrator account for Nexus.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { SystemRole } from "@capakraken/db";
|
import { SystemRole } from "@nexus/db";
|
||||||
import {
|
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||||
PASSWORD_MAX_LENGTH,
|
|
||||||
PASSWORD_MIN_LENGTH,
|
|
||||||
PASSWORD_POLICY_MESSAGE,
|
|
||||||
} from "@capakraken/shared";
|
|
||||||
|
|
||||||
export type SetupResult =
|
export type SetupResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { prisma } from "@capakraken/db";
|
import { prisma } from "@nexus/db";
|
||||||
import { SetupClient } from "./SetupClient.js";
|
import { SetupClient } from "./SetupClient.js";
|
||||||
|
|
||||||
export default async function SetupPage() {
|
export default async function SetupPage() {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
|
|||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
||||||
import { assertSpreadsheetFile } from "~/lib/excel.js";
|
import { assertSpreadsheetFile } from "~/lib/excel.js";
|
||||||
import type { SkillEntry } from "@capakraken/shared";
|
import type { SkillEntry } from "@nexus/shared";
|
||||||
|
|
||||||
interface ParsedEntry {
|
interface ParsedEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
candidateEid: string; // guessed from filename (no extension, lowercased)
|
candidateEid: string; // guessed from filename (no extension, lowercased)
|
||||||
selectedEid: string;
|
selectedEid: string;
|
||||||
skills: SkillEntry[];
|
skills: SkillEntry[];
|
||||||
employeeInfo: Record<string, string>;
|
employeeInfo: Record<string, string>;
|
||||||
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
||||||
onSuccess: (data) => { setResult(data); setSubmitting(false); },
|
onSuccess: (data) => {
|
||||||
onError: (err) => { setError(err.message); setSubmitting(false); },
|
setResult(data);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
|
|||||||
|
|
||||||
const empInfo: Record<string, string> = {};
|
const empInfo: Record<string, string> = {};
|
||||||
if (roleId) empInfo["roleId"] = roleId;
|
if (roleId) empInfo["roleId"] = roleId;
|
||||||
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
if (result.employeeInfo.portfolioUrl)
|
||||||
|
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
|
|||||||
skills: e.skills,
|
skills: e.skills,
|
||||||
employeeInfo: {
|
employeeInfo: {
|
||||||
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
||||||
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
|
...(e.employeeInfo["portfolioUrl"]
|
||||||
|
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl">
|
<div className="p-6 max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Batch Skill Matrix Import
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
||||||
</p>
|
</p>
|
||||||
@@ -149,12 +160,33 @@ export function BatchSkillImport() {
|
|||||||
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
>
|
>
|
||||||
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
|
Click to select multiple .xlsx files
|
||||||
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Name files after resource EID or display name for automatic matching
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFiles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
||||||
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
||||||
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
|
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
|
||||||
|
unmatched (select EID manually)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
|
File
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
Resource EID
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Skills
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Role Match
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
{entries.map((entry, idx) => (
|
{entries.map((entry, idx) => (
|
||||||
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
|
<tr
|
||||||
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
|
key={idx}
|
||||||
|
className={
|
||||||
|
entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">
|
||||||
|
{entry.fileName}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{entry.status === "matched" ? (
|
{entry.status === "matched" ? (
|
||||||
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
|
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
|
||||||
|
{entry.selectedEid}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
|
|||||||
>
|
>
|
||||||
<option value="">— Select resource —</option>
|
<option value="">— Select resource —</option>
|
||||||
{resourceList.map((r) => (
|
{resourceList.map((r) => (
|
||||||
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
|
<option key={r.eid} value={r.eid}>
|
||||||
|
{r.displayName} ({r.eid})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
|
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
||||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
|
{entry.skills.length}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{entry.matchedRoleName ?? "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
<span
|
||||||
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||||
}`}>
|
entry.status === "matched"
|
||||||
|
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||||
|
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{entry.status}
|
{entry.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
|
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||||
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
|
Import complete: <strong>{result.updated}</strong> updated,{" "}
|
||||||
|
<strong>{result.notFound}</strong> not found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
|
|||||||
disabled={submitting || matched === 0}
|
disabled={submitting || matched === 0}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
{submitting
|
||||||
|
? "Importing…"
|
||||||
|
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SystemRole } from "@capakraken/shared";
|
import { SystemRole } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
|
|
||||||
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
|||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!email) { setError("Email is required."); return; }
|
if (!email) {
|
||||||
|
setError("Email is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await inviteMutation.mutateAsync({ email, role });
|
await inviteMutation.mutateAsync({ email, role });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +99,9 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
|||||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
>
|
>
|
||||||
{ROLE_OPTIONS.map((opt) => (
|
{ROLE_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PermissionKey } from "@capakraken/shared";
|
import { PermissionKey } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
|
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
|
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
|
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import type { PermissionKey } from "@capakraken/shared";
|
import type { PermissionKey } from "@nexus/shared";
|
||||||
import {
|
import {
|
||||||
SystemRole,
|
SystemRole,
|
||||||
ROLE_DEFAULT_PERMISSIONS,
|
ROLE_DEFAULT_PERMISSIONS,
|
||||||
MILLISECONDS_PER_DAY,
|
MILLISECONDS_PER_DAY,
|
||||||
type PermissionOverrides,
|
type PermissionOverrides,
|
||||||
} from "@capakraken/shared";
|
} from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { InviteUserModal } from "./InviteUserModal.js";
|
import { InviteUserModal } from "./InviteUserModal.js";
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function WebhooksClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Configure outbound webhooks to notify external services about events in CapaKraken.
|
Configure outbound webhooks to notify external services about events in Nexus.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||||
@@ -194,10 +194,7 @@ export function WebhooksClient() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{webhooks.map((wh) => (
|
{webhooks.map((wh) => (
|
||||||
<div
|
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
|
||||||
key={wh.id}
|
|
||||||
className="app-surface flex items-center gap-4 p-4"
|
|
||||||
>
|
|
||||||
{/* Active indicator */}
|
{/* Active indicator */}
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 shrink-0 rounded-full ${
|
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||||
@@ -209,9 +206,7 @@ export function WebhooksClient() {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
|
||||||
{wh.name}
|
|
||||||
</span>
|
|
||||||
{wh.url.includes("hooks.slack.com") && (
|
{wh.url.includes("hooks.slack.com") && (
|
||||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
Slack
|
Slack
|
||||||
@@ -257,17 +252,12 @@ export function WebhooksClient() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={SECONDARY_BUTTON}
|
className={SECONDARY_BUTTON}
|
||||||
onClick={() =>
|
onClick={() => handleToggleActive(wh.id, wh.isActive)}
|
||||||
handleToggleActive(wh.id, wh.isActive)
|
|
||||||
}
|
|
||||||
disabled={updateMut.isPending}
|
disabled={updateMut.isPending}
|
||||||
>
|
>
|
||||||
{wh.isActive ? "Disable" : "Enable"}
|
{wh.isActive ? "Disable" : "Enable"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
|
||||||
className={SECONDARY_BUTTON}
|
|
||||||
onClick={() => openEditModal(wh)}
|
|
||||||
>
|
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{deleteConfirmId === wh.id ? (
|
{deleteConfirmId === wh.id ? (
|
||||||
@@ -282,18 +272,12 @@ export function WebhooksClient() {
|
|||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
|
||||||
className={SECONDARY_BUTTON}
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
|
||||||
className={SECONDARY_BUTTON}
|
|
||||||
onClick={() => setDeleteConfirmId(wh.id)}
|
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -335,9 +319,7 @@ export function WebhooksClient() {
|
|||||||
|
|
||||||
{/* Secret */}
|
{/* Secret */}
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL_CLASS}>
|
<label className={LABEL_CLASS}>Secret (optional)</label>
|
||||||
Secret (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import {
|
import {
|
||||||
INPUT_CLASS,
|
INPUT_CLASS,
|
||||||
@@ -123,7 +123,9 @@ export function AiProviderPanel({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{urlParsedType === "completions" ? (
|
{urlParsedType === "completions" ? (
|
||||||
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
<p className="text-xs text-green-700 dark:text-green-400">
|
||||||
|
All fields filled from URL.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,7 +156,7 @@ export function AiProviderPanel({
|
|||||||
id="ai-model"
|
id="ai-model"
|
||||||
type="text"
|
type="text"
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
||||||
value={model}
|
value={model}
|
||||||
onChange={(event) => onModelChange(event.target.value)}
|
onChange={(event) => onModelChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -223,12 +225,7 @@ export function AiProviderPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
||||||
type="button"
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={PRIMARY_BUTTON_CLASS}
|
|
||||||
>
|
|
||||||
{isSaving ? "Saving…" : "Save Settings"}
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-1">
|
<div className="flex items-center gap-3 pt-1">
|
||||||
<button
|
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
||||||
type="button"
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={PRIMARY_BUTTON_CLASS}
|
|
||||||
>
|
|
||||||
{isSaving ? "Saving…" : "Save Settings"}
|
{isSaving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
{saved ? (
|
{saved ? (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
|
|||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
value={smtpFrom}
|
value={smtpFrom}
|
||||||
onChange={(event) => setSmtpFrom(event.target.value)}
|
onChange={(event) => setSmtpFrom(event.target.value)}
|
||||||
placeholder="noreply@capakraken.app"
|
placeholder="noreply@nexus.app"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
|
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
|
||||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
||||||
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
|
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
|
||||||
import { formatDate } from "~/lib/format.js";
|
import { formatDate } from "~/lib/format.js";
|
||||||
import { AllocationRow } from "./AllocationRow.js";
|
import { AllocationRow } from "./AllocationRow.js";
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { AllocationStatus } from "@capakraken/shared";
|
import { AllocationStatus } from "@nexus/shared";
|
||||||
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
|
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
import { toDateInputValue } from "~/lib/format.js";
|
import { toDateInputValue } from "~/lib/format.js";
|
||||||
@@ -26,7 +26,8 @@ interface AllocationModalProps {
|
|||||||
|
|
||||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||||
const isEditing = Boolean(allocation);
|
const isEditing = Boolean(allocation);
|
||||||
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
const initialEntryKind: EntryKind =
|
||||||
|
allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||||
const isDemandEntry = entryKind === "demand";
|
const isDemandEntry = entryKind === "demand";
|
||||||
|
|
||||||
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{ isActive: true, limit: 500 },
|
{ isActive: true, limit: 500 },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
const { data: projects } = trpc.project.list.useQuery(
|
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
|
||||||
{ limit: 500 },
|
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||||
{ staleTime: 60_000 },
|
|
||||||
);
|
|
||||||
const { data: rolesData } = trpc.role.list.useQuery(
|
|
||||||
{ isActive: true },
|
|
||||||
{ staleTime: 60_000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch existing allocations for the selected resource+project to detect overlaps
|
// Fetch existing allocations for the selected resource+project to detect overlaps
|
||||||
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
||||||
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
const shouldCheckConflicts =
|
const shouldCheckConflicts =
|
||||||
!isDemandEntry &&
|
!isDemandEntry &&
|
||||||
!!debouncedResourceId &&
|
!!debouncedResourceId &&
|
||||||
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
|
conflictCheckStart !== null &&
|
||||||
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
|
!isNaN(conflictCheckStart.getTime()) &&
|
||||||
|
conflictCheckEnd !== null &&
|
||||||
|
!isNaN(conflictCheckEnd.getTime()) &&
|
||||||
debouncedHoursPerDay > 0;
|
debouncedHoursPerDay > 0;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const { data: conflictResult, isFetching: checkingConflicts } =
|
||||||
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
{
|
(trpc.allocation.checkConflicts.useQuery as any)(
|
||||||
resourceId: debouncedResourceId,
|
{
|
||||||
startDate: conflictCheckStart,
|
resourceId: debouncedResourceId,
|
||||||
endDate: conflictCheckEnd,
|
startDate: conflictCheckStart,
|
||||||
hoursPerDay: debouncedHoursPerDay,
|
endDate: conflictCheckEnd,
|
||||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
hoursPerDay: debouncedHoursPerDay,
|
||||||
},
|
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
},
|
||||||
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
|
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||||
|
) as {
|
||||||
|
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
|
||||||
|
isFetching: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const overlapWarning = useMemo(() => {
|
const overlapWarning = useMemo(() => {
|
||||||
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
||||||
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
const formEnd = new Date(endDate);
|
const formEnd = new Date(endDate);
|
||||||
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
||||||
|
|
||||||
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
|
const allocList =
|
||||||
|
(
|
||||||
|
existingAllocations as {
|
||||||
|
allocations?: Array<{
|
||||||
|
id: string;
|
||||||
|
resourceId?: string | null;
|
||||||
|
startDate: string | Date;
|
||||||
|
endDate: string | Date;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).allocations ?? [];
|
||||||
for (const existing of allocList) {
|
for (const existing of allocList) {
|
||||||
// Skip the allocation being edited
|
// Skip the allocation being edited
|
||||||
if (isEditing && allocation && existing.id === allocation.id) continue;
|
if (isEditing && allocation && existing.id === allocation.id) continue;
|
||||||
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
|
}, [
|
||||||
|
shouldCheckOverlap,
|
||||||
|
existingAllocations,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
isEditing,
|
||||||
|
allocation,
|
||||||
|
resourceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
|
|
||||||
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setServerError(null);
|
setServerError(null);
|
||||||
setOverbookingAcknowledged(false);
|
setOverbookingAcknowledged(false);
|
||||||
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
}, [
|
||||||
|
resourceId,
|
||||||
|
projectId,
|
||||||
|
roleId,
|
||||||
|
roleFreeText,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
hoursPerDay,
|
||||||
|
status,
|
||||||
|
entryKind,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
// Determine role string from roleId if set
|
// Determine role string from roleId if set
|
||||||
const rolesList = rolesData ?? [];
|
const rolesList = rolesData ?? [];
|
||||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||||
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
|
||||||
|
|
||||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||||
|
|
||||||
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
id: getPlanningEntryMutationId(allocation),
|
id: getPlanningEntryMutationId(allocation),
|
||||||
data: {
|
data: {
|
||||||
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
resourceId: isDemandEntry ? undefined : resourceId || undefined,
|
||||||
projectId,
|
projectId,
|
||||||
role: roleString,
|
role: roleString,
|
||||||
roleId: roleId || undefined,
|
roleId: roleId || undefined,
|
||||||
headcount: isDemandEntry ? headcount : 1,
|
headcount: isDemandEntry ? headcount : 1,
|
||||||
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
|
...(isDemandEntry && budgetEur
|
||||||
|
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
|
||||||
|
: {}),
|
||||||
startDate: start,
|
startDate: start,
|
||||||
endDate: end,
|
endDate: end,
|
||||||
hoursPerDay,
|
hoursPerDay,
|
||||||
@@ -279,18 +310,22 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
|
|
||||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
const resourceList = (resources?.resources ?? []) as Array<{
|
||||||
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
}>;
|
||||||
|
const projectList = (projects?.projects ?? []) as Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortCode: string;
|
||||||
|
}>;
|
||||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
||||||
<div
|
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
data-testid="allocation-modal"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{isDemandEntry && (
|
{isDemandEntry && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
Headcount:
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={headcount}
|
value={headcount}
|
||||||
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
|
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
Budget (EUR):
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={budgetEur}
|
value={budgetEur}
|
||||||
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{!isDemandEntry && (
|
{!isDemandEntry && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-resource" className={labelClass}>
|
<label htmlFor="modal-resource" className={labelClass}>
|
||||||
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
Resource <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-resource"
|
id="modal-resource"
|
||||||
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{/* Project */}
|
{/* Project */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-project" className={labelClass}>
|
<label htmlFor="modal-project" className={labelClass}>
|
||||||
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
Project <span className="text-red-500">*</span>
|
||||||
|
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-project"
|
id="modal-project"
|
||||||
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
|
<label htmlFor="modal-role" className={labelClass}>
|
||||||
|
Role
|
||||||
|
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-role"
|
id="modal-role"
|
||||||
value={roleId}
|
value={roleId}
|
||||||
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{/* Dates */}
|
{/* Dates */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
|
<span className={labelClass}>
|
||||||
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
|
Date Range <span className="text-red-500">*</span>
|
||||||
|
</span>
|
||||||
|
<DateRangePresets
|
||||||
|
onSelect={(s, e) => {
|
||||||
|
setStartDate(s);
|
||||||
|
setEndDate(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-start" className={labelClass}>
|
<label htmlFor="modal-start" className={labelClass}>
|
||||||
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
|
Start Date{" "}
|
||||||
</label>
|
<InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||||
<DateInput
|
</label>
|
||||||
id="modal-start"
|
<DateInput
|
||||||
value={startDate}
|
id="modal-start"
|
||||||
onChange={setStartDate}
|
value={startDate}
|
||||||
className={inputClass}
|
onChange={setStartDate}
|
||||||
required
|
className={inputClass}
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
<div>
|
</div>
|
||||||
<label htmlFor="modal-end" className={labelClass}>
|
<div>
|
||||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
<label htmlFor="modal-end" className={labelClass}>
|
||||||
</label>
|
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||||
<DateInput
|
</label>
|
||||||
id="modal-end"
|
<DateInput
|
||||||
value={endDate}
|
id="modal-end"
|
||||||
onChange={setEndDate}
|
value={endDate}
|
||||||
min={startDate}
|
onChange={setEndDate}
|
||||||
className={inputClass}
|
min={startDate}
|
||||||
required
|
className={inputClass}
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-hours" className={labelClass}>
|
<label htmlFor="modal-hours" className={labelClass}>
|
||||||
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
Hours / Day
|
||||||
|
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="modal-hours"
|
id="modal-hours"
|
||||||
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="modal-status" className={labelClass}>
|
<label htmlFor="modal-status" className={labelClass}>
|
||||||
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
Status
|
||||||
|
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="modal-status"
|
id="modal-status"
|
||||||
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
}}
|
}}
|
||||||
className="rounded border-gray-300 dark:border-gray-600"
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Recurring schedule
|
||||||
|
</span>
|
||||||
|
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||||
</label>
|
</label>
|
||||||
{isRecurring && (
|
{isRecurring && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
)}
|
)}
|
||||||
{!conflictResult && checkingConflicts && (
|
{!conflictResult && checkingConflicts && (
|
||||||
<ConflictWarningPanel
|
<ConflictWarningPanel
|
||||||
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
|
result={{
|
||||||
|
isOverbooking: false,
|
||||||
|
overbooking: null,
|
||||||
|
vacationOverlap: [],
|
||||||
|
hasVacationOverlap: false,
|
||||||
|
}}
|
||||||
isLoading={true}
|
isLoading={true}
|
||||||
acknowledged={false}
|
acknowledged={false}
|
||||||
onAcknowledge={() => {}}
|
onAcknowledge={() => {}}
|
||||||
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || hasUnacknowledgedOverbooking}
|
disabled={isPending || hasUnacknowledgedOverbooking}
|
||||||
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
|
title={
|
||||||
|
hasUnacknowledgedOverbooking
|
||||||
|
? "Acknowledge the overbooking warning above to proceed"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isPending ? "Saving…" : "Save"}
|
{isPending ? "Saving…" : "Save"}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
||||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||||
|
|
||||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import type {
|
|||||||
AllocationWithDetails,
|
AllocationWithDetails,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
AllocationStatus,
|
AllocationStatus,
|
||||||
} from "@capakraken/shared";
|
} from "@nexus/shared";
|
||||||
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
|
import { ALLOCATION_COLUMNS } from "@nexus/shared";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||||
@@ -328,7 +328,7 @@ export function AllocationsClient() {
|
|||||||
|
|
||||||
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
||||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
||||||
"capakraken:allocations:viewMode",
|
"nexus:allocations:viewMode",
|
||||||
"grouped",
|
"grouped",
|
||||||
);
|
);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AllocationConflictCheckResult } from "@capakraken/shared";
|
import type { AllocationConflictCheckResult } from "@nexus/shared";
|
||||||
|
|
||||||
const INITIAL_ROWS_SHOWN = 5;
|
const INITIAL_ROWS_SHOWN = 5;
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
|
|||||||
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
|
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
|
||||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||||
⚠ Overbooking on {result.overbooking.totalConflictDays} day
|
⚠ Overbooking on {result.overbooking.totalConflictDays} day
|
||||||
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
|
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
|
||||||
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
|
{result.overbooking.maxOverbookPercent}% over capacity)
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-amber-700 dark:text-amber-400">
|
<p className="mt-1 text-amber-700 dark:text-amber-400">
|
||||||
The resource already has allocations that exceed their daily capacity on the following days.
|
The resource already has allocations that exceed their daily capacity on the following
|
||||||
You can still save — check the box below to confirm.
|
days. You can still save — check the box below to confirm.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Day-by-day table */}
|
{/* Day-by-day table */}
|
||||||
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleDays.map((day) => (
|
{visibleDays.map((day) => (
|
||||||
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
|
<tr
|
||||||
|
key={day.date}
|
||||||
|
className="border-b border-amber-100 dark:border-amber-900/50 last:border-0"
|
||||||
|
>
|
||||||
<td className="py-1 pr-4">{day.date}</td>
|
<td className="py-1 pr-4">{day.date}</td>
|
||||||
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
|
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
|
||||||
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
|
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
|
||||||
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
|
|||||||
onClick={() => setShowAllDays((v) => !v)}
|
onClick={() => setShowAllDays((v) => !v)}
|
||||||
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
|
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
|
||||||
>
|
>
|
||||||
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
{showAllDays
|
||||||
|
? "Show less"
|
||||||
|
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
{result.vacationOverlap.map((v, i) => (
|
{result.vacationOverlap.map((v, i) => (
|
||||||
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400"
|
||||||
|
>
|
||||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
|
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
|
||||||
<span className="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
|
<span className="font-medium capitalize">
|
||||||
|
{v.type.replace(/_/g, " ").toLowerCase()}
|
||||||
|
</span>
|
||||||
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
|
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
|
||||||
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}</span>
|
<span>
|
||||||
|
{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useMemo } from "react";
|
import { useRef, useState, useMemo } from "react";
|
||||||
import { AllocationStatus } from "@capakraken/shared";
|
import { AllocationStatus } from "@nexus/shared";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const { data: resources } = trpc.resource.listStaff.useQuery(
|
const { data: resources } = trpc.resource.listStaff.useQuery(
|
||||||
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
||||||
{ staleTime: 15_000 },
|
{ staleTime: 15_000 },
|
||||||
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
|
) as {
|
||||||
|
data:
|
||||||
|
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
|
||||||
|
| undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
|
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
|
||||||
{
|
{
|
||||||
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const lcrCents = selectedResource.lcrCents ?? 0;
|
const lcrCents = selectedResource.lcrCents ?? 0;
|
||||||
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
|
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
|
||||||
|
|
||||||
setPlanned((prev) => [...prev, {
|
setPlanned((prev) => [
|
||||||
resourceId: selectedResource.id,
|
...prev,
|
||||||
resourceName: selectedResource.displayName,
|
{
|
||||||
eid: selectedResource.eid,
|
resourceId: selectedResource.id,
|
||||||
hoursPerDay,
|
resourceName: selectedResource.displayName,
|
||||||
availableHours: avail.totalAvailableHours,
|
eid: selectedResource.eid,
|
||||||
availableDays: avail.availableDays,
|
hoursPerDay,
|
||||||
conflictDays: avail.conflictDays,
|
availableHours: avail.totalAvailableHours,
|
||||||
coveragePercent: avail.coveragePercent,
|
availableDays: avail.availableDays,
|
||||||
estimatedCostCents,
|
conflictDays: avail.conflictDays,
|
||||||
}]);
|
coveragePercent: avail.coveragePercent,
|
||||||
|
estimatedCostCents,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Reset for next resource
|
// Reset for next resource
|
||||||
setResourceId("");
|
setResourceId("");
|
||||||
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
status: AllocationStatus.PROPOSED,
|
status: AllocationStatus.PROPOSED,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
|
setServerError(
|
||||||
|
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !submitting) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||||
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape" && !submitting) onClose();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
||||||
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">×</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 pt-4 pb-2 space-y-3">
|
<div className="px-6 pt-4 pb-2 space-y-3">
|
||||||
{/* Demand summary */}
|
{/* Demand summary */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
|
||||||
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: roleColor }}
|
||||||
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} – {formatDateMedium(allocation.endDate)}
|
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} –{" "}
|
||||||
|
{formatDateMedium(allocation.endDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
||||||
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
|
{allocation.budgetCents && allocation.budgetCents > 0
|
||||||
|
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +239,10 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
||||||
<span>Demand coverage</span>
|
<span>Demand coverage</span>
|
||||||
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
|
<span>
|
||||||
|
{Math.round(consumedHours)}h / {totalDemandHours}h (
|
||||||
|
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
||||||
{planned.map((r, i) => (
|
{planned.map((r, i) => (
|
||||||
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{planned.map((r, i) => (
|
{planned.map((r, i) => (
|
||||||
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
|
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
|
||||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
|
<div
|
||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
||||||
|
{r.resourceName}
|
||||||
|
</span>
|
||||||
<span className="text-gray-400">({r.eid})</span>
|
<span className="text-gray-400">({r.eid})</span>
|
||||||
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
|
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
|
||||||
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
|
<span className="ml-auto text-gray-500">
|
||||||
|
{Math.round(r.availableHours)}h · {r.coveragePercent}%
|
||||||
|
</span>
|
||||||
{phase === "plan" && (
|
{phase === "plan" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{remainingHours > 0 && (
|
{remainingHours > 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||||
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
|
<span className="text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
Remaining: {Math.round(remainingHours)}h
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{phase === "plan" && (
|
{phase === "plan" && (
|
||||||
<div className="px-6 pb-5 space-y-4">
|
<div className="px-6 pb-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Search Resource
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or EID..."
|
placeholder="Search by name or EID..."
|
||||||
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Select Resource
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={resourceId}
|
value={resourceId}
|
||||||
onChange={(e) => setResourceId(e.target.value)}
|
onChange={(e) => setResourceId(e.target.value)}
|
||||||
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Hours / Day
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={hoursPerDay}
|
value={hoursPerDay}
|
||||||
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
|
|
||||||
{/* Availability preview */}
|
{/* Availability preview */}
|
||||||
{resourceId && avail && (
|
{resourceId && avail && (
|
||||||
<div className={`rounded-lg p-3 border text-sm ${
|
<div
|
||||||
avail.coveragePercent >= 100
|
className={`rounded-lg p-3 border text-sm ${
|
||||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
avail.coveragePercent >= 100
|
||||||
: avail.coveragePercent >= 50
|
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||||
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
: avail.coveragePercent >= 50
|
||||||
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
||||||
}`}>
|
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
|
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
|
||||||
Availability: {avail.resource.name}
|
Availability: {avail.resource.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Available</span>
|
<span className="text-gray-500 dark:text-gray-400">Available</span>
|
||||||
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
|
<div className="font-semibold text-green-700 dark:text-green-400">
|
||||||
|
{avail.availableDays} days
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
|
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
|
||||||
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
|
<div className="font-semibold text-red-700 dark:text-red-400">
|
||||||
|
{avail.conflictDays} days
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400">Hours</span>
|
<span className="text-gray-500 dark:text-gray-400">Hours</span>
|
||||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
|
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{avail.existingAssignments.length > 0 && (
|
{avail.existingAssignments.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Existing bookings:</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Existing bookings:
|
||||||
|
</div>
|
||||||
{avail.existingAssignments.slice(0, 4).map((a, i) => (
|
{avail.existingAssignments.slice(0, 4).map((a, i) => (
|
||||||
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
|
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
|
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{avail.existingAssignments.length > 4 && (
|
{avail.existingAssignments.length > 4 && (
|
||||||
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
|
<div className="text-xs text-gray-400">
|
||||||
|
+{avail.existingAssignments.length - 4} more
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{resourceId && availabilityQuery.isLoading && (
|
{resourceId && availabilityQuery.isLoading && (
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
|
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
|
||||||
|
Checking availability...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center justify-between gap-3 pt-2">
|
<div className="flex items-center justify-between gap-3 pt-2">
|
||||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
|
Resource
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
|
h/day
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Hours
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center justify-end gap-0.5">
|
||||||
|
Est. Cost
|
||||||
|
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center justify-end gap-0.5">
|
||||||
|
Coverage
|
||||||
|
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{r.resourceName}
|
{r.resourceName}
|
||||||
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
|
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
|
{r.hoursPerDay}h
|
||||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{Math.round(r.availableHours)}h
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{formatCents(r.estimatedCostCents)} EUR
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
|
<span
|
||||||
|
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
|
||||||
|
>
|
||||||
{r.coveragePercent}%
|
{r.coveragePercent}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot className="bg-gray-50 dark:bg-gray-900">
|
<tfoot className="bg-gray-50 dark:bg-gray-900">
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
|
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Total
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2" />
|
<td className="px-3 py-2" />
|
||||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{Math.round(consumedHours)}h / {totalDemandHours}h
|
{Math.round(consumedHours)}h / {totalDemandHours}h
|
||||||
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
|
{totalDemandHours > 0
|
||||||
|
? Math.round((consumedHours / totalDemandHours) * 100)
|
||||||
|
: 0}
|
||||||
|
%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{allocation.budgetCents && allocation.budgetCents > 0 && (
|
{allocation.budgetCents && allocation.budgetCents > 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Role Budget:
|
||||||
|
</td>
|
||||||
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||||
{formatCents(allocation.budgetCents)} EUR
|
{formatCents(allocation.budgetCents)} EUR
|
||||||
</td>
|
</td>
|
||||||
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
|
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
|
||||||
const remain = allocation.budgetCents! - totalCost;
|
const remain = allocation.budgetCents! - totalCost;
|
||||||
return (
|
return (
|
||||||
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
|
<span
|
||||||
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
|
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
|
||||||
|
>
|
||||||
|
{remain < 0
|
||||||
|
? `${formatCents(Math.abs(remain))} over`
|
||||||
|
: `${formatCents(remain)} left`}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
|
|
||||||
{remainingHours > 0 && (
|
{remainingHours > 0 && (
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
|
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
|
||||||
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
|
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
|
||||||
|
partially.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
|||||||
disabled={submitting || planned.length === 0}
|
disabled={submitting || planned.length === 0}
|
||||||
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
|
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
{submitting
|
||||||
|
? `Assigning ${submitProgress}/${planned.length}...`
|
||||||
|
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllocationWithDetails } from "@capakraken/shared";
|
import type { AllocationWithDetails } from "@nexus/shared";
|
||||||
|
|
||||||
type DemandRow = AllocationWithDetails & {
|
type DemandRow = AllocationWithDetails & {
|
||||||
sourceAllocationId?: string;
|
sourceAllocationId?: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RecurrenceFrequency } from "@capakraken/shared";
|
import { RecurrenceFrequency } from "@nexus/shared";
|
||||||
import type { RecurrencePattern } from "@capakraken/shared";
|
import type { RecurrencePattern } from "@nexus/shared";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
@@ -39,7 +39,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
{/* Frequency selector */}
|
{/* Frequency selector */}
|
||||||
<div>
|
<div>
|
||||||
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
|
<span className={labelClass}>
|
||||||
|
Frequency
|
||||||
|
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
|
||||||
|
</span>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{Object.values(RecurrenceFrequency).map((f) => (
|
{Object.values(RecurrenceFrequency).map((f) => (
|
||||||
<button
|
<button
|
||||||
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{f === RecurrenceFrequency.WEEKLY
|
{f === RecurrenceFrequency.WEEKLY
|
||||||
? "Weekly"
|
? "Weekly"
|
||||||
: f === RecurrenceFrequency.BIWEEKLY
|
: f === RecurrenceFrequency.BIWEEKLY
|
||||||
? "Biweekly"
|
? "Biweekly"
|
||||||
: f === RecurrenceFrequency.MONTHLY
|
: f === RecurrenceFrequency.MONTHLY
|
||||||
? "Monthly"
|
? "Monthly"
|
||||||
: "Custom"}
|
: "Custom"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||||
<div>
|
<div>
|
||||||
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
|
<span className={labelClass}>
|
||||||
|
Days of week
|
||||||
|
<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." />
|
||||||
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{WEEKDAY_LABELS.map((label, dow) => {
|
{WEEKDAY_LABELS.map((label, dow) => {
|
||||||
const selected = (value?.weekdays ?? []).includes(dow);
|
const selected = (value?.weekdays ?? []).includes(dow);
|
||||||
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
|||||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
|
<label className={labelClass}>
|
||||||
|
Hours per recurring day (optional override)
|
||||||
|
<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.5}
|
min={0.5}
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ interface AssistantInsight {
|
|||||||
sections?: AssistantInsightSection[];
|
sections?: AssistantInsightSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "capakraken-chat-messages";
|
const STORAGE_KEY = "nexus-chat-messages";
|
||||||
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
|
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
|
||||||
|
|
||||||
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { FieldType } from "@capakraken/shared";
|
import { FieldType } from "@nexus/shared";
|
||||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||||
import { FieldCard } from "./FieldCard.js";
|
import { FieldCard } from "./FieldCard.js";
|
||||||
@@ -48,10 +48,7 @@ interface FieldState {
|
|||||||
// Helpers: Convert between FieldState and BlueprintFieldDefinition
|
// Helpers: Convert between FieldState and BlueprintFieldDefinition
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function fieldDefToState(
|
function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
|
||||||
def: BlueprintFieldDefinition,
|
|
||||||
target: BlueprintTargetValue,
|
|
||||||
): FieldState {
|
|
||||||
const catalogField = findCatalogField(target, def.key);
|
const catalogField = findCatalogField(target, def.key);
|
||||||
if (catalogField) {
|
if (catalogField) {
|
||||||
return {
|
return {
|
||||||
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
|
|||||||
// Build initial state from existing fieldDefs + catalog
|
// Build initial state from existing fieldDefs + catalog
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const [catalogOverrides, setCatalogOverrides] = useState<
|
const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
|
||||||
Record<string, FieldOverrides>
|
|
||||||
>(() => {
|
|
||||||
const map: Record<string, FieldOverrides> = {};
|
const map: Record<string, FieldOverrides> = {};
|
||||||
// Start with all catalog fields disabled
|
// Start with all catalog fields disabled
|
||||||
for (const cf of catalog) {
|
for (const cf of catalog) {
|
||||||
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
|
|||||||
// Handlers
|
// Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const handleCatalogFieldChange = useCallback(
|
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
|
||||||
(key: string, overrides: FieldOverrides) => {
|
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
||||||
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCustomFieldChange = useCallback(
|
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
|
||||||
(idx: number, overrides: FieldOverrides) => {
|
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
|
||||||
setCustomFields((prev) =>
|
}, []);
|
||||||
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
function removeCustomField(idx: number) {
|
function removeCustomField(idx: number) {
|
||||||
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
|
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
|
||||||
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
|
|||||||
// Collapsed categories
|
// Collapsed categories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
|
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleCategory(name: string) {
|
function toggleCategory(name: string) {
|
||||||
setCollapsedCategories((prev) => {
|
setCollapsedCategories((prev) => {
|
||||||
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
|
|||||||
{/* Field cards */}
|
{/* Field cards */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
|
||||||
{categories
|
{categories
|
||||||
.filter(
|
.filter((cat) => activeCategory === null || activeCategory === cat.name)
|
||||||
(cat) =>
|
|
||||||
activeCategory === null ||
|
|
||||||
activeCategory === cat.name,
|
|
||||||
)
|
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const fields = fieldsByCategory.get(cat.name) ?? [];
|
const fields = fieldsByCategory.get(cat.name) ?? [];
|
||||||
if (fields.length === 0 && searchQuery.trim()) return null;
|
if (fields.length === 0 && searchQuery.trim()) return null;
|
||||||
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
|
if (
|
||||||
|
fields.length === 0 &&
|
||||||
|
activeCategory !== null &&
|
||||||
|
activeCategory !== cat.name
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
const isCollapsed = collapsedCategories.has(cat.name);
|
const isCollapsed = collapsedCategories.has(cat.name);
|
||||||
|
|
||||||
@@ -527,9 +513,7 @@ export function BlueprintFieldCatalog({
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">{cat.description}</span>
|
||||||
{cat.description}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
field={field}
|
field={field}
|
||||||
overrides={catalogOverrides[field.key]!}
|
overrides={catalogOverrides[field.key]!}
|
||||||
onChange={(ov) =>
|
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
|
||||||
handleCatalogFieldChange(field.key, ov)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{fields.length === 0 && (
|
{fields.length === 0 && (
|
||||||
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Custom Fields section */}
|
{/* Custom Fields section */}
|
||||||
{(activeCategory === null ||
|
{(activeCategory === null || activeCategory === "Custom Fields") && (
|
||||||
activeCategory === "Custom Fields") && (
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
|
|||||||
className="flex items-center gap-2 mb-3 w-full text-left group"
|
className="flex items-center gap-2 mb-3 w-full text-left group"
|
||||||
>
|
>
|
||||||
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
|
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
|
||||||
{collapsedCategories.has("Custom Fields")
|
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
|
||||||
? "\u25B6"
|
|
||||||
: "\u25BC"}
|
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||||
Custom Fields
|
Custom Fields
|
||||||
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
|
|||||||
label: cf.custom.label,
|
label: cf.custom.label,
|
||||||
type: cf.custom.type,
|
type: cf.custom.type,
|
||||||
category: "Custom Fields",
|
category: "Custom Fields",
|
||||||
description:
|
description: cf.overrides.description || "Custom field",
|
||||||
cf.overrides.description || "Custom field",
|
|
||||||
...(cf.custom.options.length > 0
|
...(cf.custom.options.length > 0
|
||||||
? { options: cf.custom.options }
|
? { options: cf.custom.options }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
|
|||||||
<FieldCard
|
<FieldCard
|
||||||
field={pseudoCatalog}
|
field={pseudoCatalog}
|
||||||
overrides={cf.overrides}
|
overrides={cf.overrides}
|
||||||
onChange={(ov) =>
|
onChange={(ov) => handleCustomFieldChange(idx, ov)}
|
||||||
handleCustomFieldChange(idx, ov)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">
|
<label className="text-xs font-medium text-gray-600">
|
||||||
Key{" "}
|
Key <span className="text-red-500">*</span>
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customKey}
|
value={customKey}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCustomKey(
|
setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
|
||||||
e.target.value.replace(
|
|
||||||
/[^a-zA-Z0-9_]/g,
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
placeholder="field_key"
|
placeholder="field_key"
|
||||||
className="app-input font-mono"
|
className="app-input font-mono"
|
||||||
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">
|
<label className="text-xs font-medium text-gray-600">
|
||||||
Label{" "}
|
Label <span className="text-red-500">*</span>
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customLabel}
|
value={customLabel}
|
||||||
onChange={(e) =>
|
onChange={(e) => setCustomLabel(e.target.value)}
|
||||||
setCustomLabel(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Display Label"
|
placeholder="Display Label"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-gray-600">
|
<label className="text-xs font-medium text-gray-600">Type</label>
|
||||||
Type
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={customType}
|
value={customType}
|
||||||
onChange={(e) =>
|
onChange={(e) => setCustomType(e.target.value as FieldType)}
|
||||||
setCustomType(
|
|
||||||
e.target.value as FieldType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="app-input"
|
className="app-input"
|
||||||
>
|
>
|
||||||
{FIELD_TYPES.map((ft) => (
|
{FIELD_TYPES.map((ft) => (
|
||||||
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addCustomField}
|
onClick={addCustomField}
|
||||||
disabled={
|
disabled={!customKey.trim() || !customLabel.trim()}
|
||||||
!customKey.trim() || !customLabel.trim()
|
|
||||||
}
|
|
||||||
className={BTN_PRIMARY}
|
className={BTN_PRIMARY}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
|
|||||||
onClick={() => setShowCustomForm(true)}
|
onClick={() => setShowCustomForm(true)}
|
||||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
|
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none">+</span>{" "}
|
<span className="text-lg leading-none">+</span> Add Custom Field
|
||||||
Add Custom Field
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-gray-400">
|
||||||
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
|
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
|
||||||
saved
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||||
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-4 overflow-y-auto">
|
<div className="px-6 py-4 overflow-y-auto">
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Role presets are auto-loaded in Step 3 of the Project Creation
|
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
||||||
Wizard when this blueprint is selected.
|
blueprint is selected.
|
||||||
</p>
|
</p>
|
||||||
<RolePresetsEditor
|
<RolePresetsEditor
|
||||||
initialPresets={initialRolePresets}
|
initialPresets={initialRolePresets}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FieldType } from "@capakraken/shared";
|
import { FieldType } from "@nexus/shared";
|
||||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||||
|
|
||||||
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateOption(idx: number, field: "value" | "label", val: string) {
|
function updateOption(idx: number, field: "value" | "label", val: string) {
|
||||||
const next = options.map((o, i) =>
|
const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
|
||||||
i === idx ? { ...o, [field]: val } : o,
|
|
||||||
);
|
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +109,7 @@ interface FieldRowProps {
|
|||||||
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const needsOptions =
|
const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||||
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
|
||||||
|
|
||||||
function update<K extends keyof BlueprintFieldDefinition>(
|
function update<K extends keyof BlueprintFieldDefinition>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* Drag handle placeholder */}
|
{/* Drag handle placeholder */}
|
||||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
|
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">☰</span>
|
||||||
☰
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Key */}
|
{/* Key */}
|
||||||
<input
|
<input
|
||||||
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
// Clear options when switching away from select types
|
// Clear options when switching away from select types
|
||||||
const clearedOptions =
|
const clearedOptions =
|
||||||
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
||||||
? field.options ?? []
|
? (field.options ?? [])
|
||||||
: undefined;
|
: undefined;
|
||||||
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
||||||
}}
|
}}
|
||||||
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500 font-medium">
|
<label className="text-xs text-gray-500 font-medium">Placeholder</label>
|
||||||
Placeholder
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={field.placeholder ?? ""}
|
value={field.placeholder ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => update("placeholder", e.target.value || undefined)}
|
||||||
update("placeholder", e.target.value || undefined)
|
|
||||||
}
|
|
||||||
placeholder="Placeholder text"
|
placeholder="Placeholder text"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-gray-500 font-medium">
|
<label className="text-xs text-gray-500 font-medium">Description</label>
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={field.description ?? ""}
|
value={field.description ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => update("description", e.target.value || undefined)}
|
||||||
update("description", e.target.value || undefined)
|
|
||||||
}
|
|
||||||
placeholder="Helper text"
|
placeholder="Helper text"
|
||||||
className="app-input"
|
className="app-input"
|
||||||
/>
|
/>
|
||||||
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
||||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
|
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
|
||||||
() =>
|
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
|
||||||
);
|
);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
||||||
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeField(idx: number) {
|
function removeField(idx: number) {
|
||||||
setFields((prev) =>
|
setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
|
||||||
prev
|
|
||||||
.filter((_, i) => i !== idx)
|
|
||||||
.map((f, i) => ({ ...f, order: i })),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
||||||
setFields((prev) =>
|
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
|
||||||
prev.map((f, i) => (i === idx ? updated : f)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Edit Fields:{" "}
|
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||||
<span className="text-gray-600 font-normal">{blueprintName}</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
|
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
||||||
|
blueprint is selected.
|
||||||
</p>
|
</p>
|
||||||
<RolePresetsEditor
|
<RolePresetsEditor
|
||||||
initialPresets={initialRolePresets}
|
initialPresets={initialRolePresets}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import type { BlueprintTarget } from "@capakraken/shared";
|
import type { BlueprintTarget } from "@nexus/shared";
|
||||||
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
import type { BlueprintFieldDefinition } from "@nexus/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||||
import { useSelection } from "~/hooks/useSelection.js";
|
import { useSelection } from "~/hooks/useSelection.js";
|
||||||
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
|
|||||||
}
|
}
|
||||||
initialRolePresets={
|
initialRolePresets={
|
||||||
Array.isArray(editingBlueprint.rolePresets)
|
Array.isArray(editingBlueprint.rolePresets)
|
||||||
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
|
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
initialTab={editingTab}
|
initialTab={editingTab}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user