Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12044f638e | |||
| 2383bcbdc0 | |||
| 0e9d6ec388 | |||
| 7285668c52 | |||
| 944d36bdb2 | |||
| 6ec512e302 | |||
| 4a841d5acb | |||
| 749a39097c | |||
| a58b99a33a | |||
| c5b58a5bdc | |||
| 52ddbe7377 | |||
| 19aeb2ba04 | |||
| b41c1d2501 |
@@ -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.
|
||||
# Lines starting with # are comments. Lines with no value are optional.
|
||||
@@ -12,7 +12,7 @@
|
||||
# REQUIRED — Public URL of the app (with scheme, no trailing slash).
|
||||
# Used in email links (invites, password reset) and as the Auth.js base URL.
|
||||
# Must use https:// in production.
|
||||
NEXTAUTH_URL=https://capakraken.example.com
|
||||
NEXTAUTH_URL=https://nexus.example.com
|
||||
|
||||
# REQUIRED — Secret used to sign and encrypt JWTs and session cookies.
|
||||
# Generate one with: openssl rand -base64 32
|
||||
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
|
||||
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
|
||||
# container this variable is overridden by docker-compose.yml (which routes
|
||||
# to the postgres service name on the internal network).
|
||||
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
||||
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus
|
||||
|
||||
# ─── Redis ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +65,7 @@ REDIS_PASSWORD=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=no-reply@example.com
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_FROM=CapaKraken <no-reply@example.com>
|
||||
# SMTP_FROM=Nexus <no-reply@example.com>
|
||||
# SMTP_TLS=true # "true" = SMTPS (port 465); "false" = STARTTLS or plain
|
||||
|
||||
# ─── pgAdmin (dev / Docker Compose only) ─────────────────────────────────────
|
||||
@@ -74,8 +74,8 @@ REDIS_PASSWORD=
|
||||
# Used as the password for the pgAdmin web UI (http://localhost:5050).
|
||||
PGADMIN_PASSWORD=
|
||||
|
||||
# Email shown on the pgAdmin login screen (default: admin@capakraken.dev).
|
||||
# PGADMIN_EMAIL=admin@capakraken.dev
|
||||
# Email shown on the pgAdmin login screen (default: admin@nexus.dev).
|
||||
# PGADMIN_EMAIL=admin@nexus.dev
|
||||
|
||||
# ─── Logging ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
|
||||
# that any resolved path remains inside this directory; this prevents an
|
||||
# admin (or compromised admin token) from pointing the parser at arbitrary
|
||||
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
|
||||
# DISPO_IMPORT_DIR=/var/lib/capakraken/imports
|
||||
# DISPO_IMPORT_DIR=/var/lib/nexus/imports
|
||||
|
||||
# ─── Testing (never enable in production) ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ Absolute Pfade unter `/share/Container/gitea/` sind **außerhalb** der Container
|
||||
|
||||
## Repo-Secrets für CI/CD
|
||||
|
||||
Im capakraken-Repo → **Settings → Actions → Secrets** eintragen:
|
||||
Im nexus-Repo → **Settings → Actions → Secrets** eintragen:
|
||||
|
||||
| Secret | Zweck |
|
||||
| ----------------------- | -------------------------------------- |
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in CapaKraken, please report it responsibly.
|
||||
If you discover a security vulnerability in Nexus, please report it responsibly.
|
||||
|
||||
**Do not** open a public GitHub issue for security vulnerabilities.
|
||||
|
||||
|
||||
+43
-39
@@ -114,7 +114,7 @@ jobs:
|
||||
run: pnpm db:generate
|
||||
|
||||
- name: Run assistant split regression
|
||||
run: pnpm --filter @capakraken/api test:assistant-split
|
||||
run: pnpm --filter @nexus/api test:assistant-split
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Lint — ~20s, no services needed
|
||||
@@ -159,11 +159,11 @@ jobs:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: capakraken_test
|
||||
POSTGRES_USER: capakraken
|
||||
POSTGRES_PASSWORD: capakraken_test
|
||||
POSTGRES_DB: nexus_test
|
||||
POSTGRES_USER: nexus
|
||||
POSTGRES_PASSWORD: nexus_test
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
||||
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
env:
|
||||
DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
|
||||
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test
|
||||
REDIS_URL: redis://redis:6379
|
||||
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
|
||||
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
|
||||
@@ -204,13 +204,13 @@ jobs:
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: |
|
||||
pnpm --filter @capakraken/web test:unit -- --coverage
|
||||
pnpm --filter @capakraken/engine exec vitest run --coverage
|
||||
pnpm --filter @capakraken/staffing exec vitest run --coverage
|
||||
pnpm --filter @capakraken/api exec vitest run --coverage
|
||||
pnpm --filter @capakraken/application exec vitest run --coverage
|
||||
pnpm --filter @capakraken/shared exec vitest run --coverage
|
||||
pnpm --filter @capakraken/db test:unit
|
||||
pnpm --filter @nexus/web test:unit -- --coverage
|
||||
pnpm --filter @nexus/engine exec vitest run --coverage
|
||||
pnpm --filter @nexus/staffing exec vitest run --coverage
|
||||
pnpm --filter @nexus/api exec vitest run --coverage
|
||||
pnpm --filter @nexus/application exec vitest run --coverage
|
||||
pnpm --filter @nexus/shared exec vitest run --coverage
|
||||
pnpm --filter @nexus/db test:unit
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -274,7 +274,7 @@ jobs:
|
||||
restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter @capakraken/web exec next build
|
||||
run: pnpm --filter @nexus/web exec next build
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# E2E — depends on build, needs PostgreSQL + Redis
|
||||
@@ -291,11 +291,11 @@ jobs:
|
||||
e2epg:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: capakraken_test
|
||||
POSTGRES_USER: capakraken
|
||||
POSTGRES_PASSWORD: capakraken_test
|
||||
POSTGRES_DB: nexus_test
|
||||
POSTGRES_USER: nexus
|
||||
POSTGRES_PASSWORD: nexus_test
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
||||
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
@@ -307,14 +307,14 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
env:
|
||||
DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
||||
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||
# Playwright test-server.mjs requires an explicit test DB URL.
|
||||
PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
||||
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
|
||||
# the expected target; default is "capakraken", CI uses capakraken_test.
|
||||
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
|
||||
# the expected target; default is "nexus", CI uses nexus_test.
|
||||
NEXUS_EXPECTED_DB_NAME: nexus_test
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
|
||||
REDIS_URL: redis://e2eredis:6379
|
||||
PORT: 3100
|
||||
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
|
||||
@@ -364,18 +364,18 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm --filter @capakraken/web exec playwright install --with-deps chromium
|
||||
run: pnpm --filter @nexus/web exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm --filter @capakraken/web exec playwright install-deps chromium
|
||||
run: pnpm --filter @nexus/web exec playwright install-deps chromium
|
||||
|
||||
- name: Install psql (debug schema state)
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends postgresql-client
|
||||
|
||||
- name: Push DB schema & seed
|
||||
env:
|
||||
PGPASSWORD: capakraken_test
|
||||
PGPASSWORD: nexus_test
|
||||
run: |
|
||||
# Nuke any leftover schema state from a previous job that shared the
|
||||
# postgres service container (act_runner reuses service volumes).
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
IPS=$(getent hosts e2epg | awk '{print $1}')
|
||||
PG_IP=""
|
||||
for ip in $IPS; do
|
||||
if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
||||
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
||||
PG_IP="$ip"
|
||||
echo "Locked onto postgres at $PG_IP"
|
||||
break
|
||||
@@ -406,19 +406,19 @@ jobs:
|
||||
fi
|
||||
done
|
||||
if [ -z "$PG_IP" ]; then
|
||||
echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
|
||||
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials"
|
||||
exit 1
|
||||
fi
|
||||
PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
|
||||
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test"
|
||||
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||
echo "--- DROP SCHEMA ---"
|
||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
|
||||
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
|
||||
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \
|
||||
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;"
|
||||
echo "--- prisma db push ---"
|
||||
DATABASE_URL="$PINNED_URL" pnpm --filter @capakraken/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
||||
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
||||
echo "--- tables in public after push ---"
|
||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
|
||||
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -At \
|
||||
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
||||
| tee /tmp/tables.txt
|
||||
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
# and restarts mid-run, producing cascading ECONNREFUSED failures
|
||||
# unrelated to test content. Scope CI to smoke.spec.ts; full suite
|
||||
# is run locally / in a dedicated nightly job.
|
||||
run: pnpm --filter @capakraken/web exec playwright test e2e/smoke.spec.ts
|
||||
run: pnpm --filter @nexus/web exec playwright test e2e/smoke.spec.ts
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -468,8 +468,8 @@ jobs:
|
||||
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
||||
PGADMIN_PASSWORD=ci-pgadmin
|
||||
# Must match the password baked into docker-compose.ci.yml's
|
||||
# DATABASE_URL override (capakraken_dev).
|
||||
POSTGRES_PASSWORD=capakraken_dev
|
||||
# DATABASE_URL override (nexus_dev).
|
||||
POSTGRES_PASSWORD=nexus_dev
|
||||
EOF
|
||||
|
||||
- name: Tear down any stale stack & volumes
|
||||
@@ -477,7 +477,11 @@ jobs:
|
||||
# runs. A previous run's failed migration entry in _prisma_migrations
|
||||
# causes P3009 on the next migrate deploy; wipe volumes for a truly
|
||||
# fresh deploy test every time.
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
||||
# Also tear down the legacy "capakraken" project (pre-Phase-3 rename)
|
||||
# in case old containers are still holding host ports 5433/6380.
|
||||
run: |
|
||||
docker compose -p capakraken --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
||||
docker compose --profile full -f docker-compose.yml -f docker-compose.ci.yml down -v --remove-orphans || true
|
||||
|
||||
- name: Start infrastructure (postgres + redis)
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d postgres redis
|
||||
@@ -485,7 +489,7 @@ jobs:
|
||||
- name: Wait for postgres
|
||||
run: |
|
||||
for i in $(seq 1 20); do
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
@@ -576,7 +580,7 @@ jobs:
|
||||
ln -sfn /app/packages/db/node_modules/@prisma /app/scripts/node_modules/@prisma
|
||||
ln -sfn /app/packages/db/node_modules/@node-rs /app/scripts/node_modules/@node-rs
|
||||
ln -sfn /app/packages/db/node_modules/.prisma /app/scripts/node_modules/.prisma
|
||||
node /app/scripts/setup-admin.mjs --email admin@capakraken.dev --name Admin --password admin123
|
||||
node /app/scripts/setup-admin.mjs --email admin@nexus.dev --name Admin --password admin123
|
||||
'
|
||||
|
||||
- name: Set up Node.js 20
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# CapaKraken
|
||||
# Nexus
|
||||
|
||||
## Ziel
|
||||
|
||||
CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
||||
Nexus ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-Produktionsumgebung. Der aktuelle Produktkern umfasst Timeline-Planung, Kapazitaets- und Budgetsicht, Rollenmanagement, Blueprint-basierte dynamische Felder, Skill-Matrix-Workflows und einen AI-unterstuetzten Staffing-/Profilbereich.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -19,7 +19,7 @@ CapaKraken ist ein Ressourcenplanungs- und Projektbesetzungs-Tool fuer eine 3D-P
|
||||
## Monorepo-Struktur
|
||||
|
||||
```text
|
||||
capakraken/
|
||||
nexus/
|
||||
├── apps/web
|
||||
├── packages/shared
|
||||
├── packages/db
|
||||
@@ -41,7 +41,7 @@ capakraken/
|
||||
## Quality Gates
|
||||
|
||||
- `pnpm test:unit`
|
||||
- `pnpm --filter @capakraken/web exec tsc --noEmit`
|
||||
- `pnpm --filter @nexus/web exec tsc --noEmit`
|
||||
- `pnpm lint`
|
||||
|
||||
## Dokumente
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm --filter @capakraken/db db:generate
|
||||
RUN pnpm --filter @nexus/db db:generate
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
|
||||
+3
-3
@@ -39,7 +39,7 @@ COPY --from=deps /app/ ./
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm --filter @capakraken/db db:generate
|
||||
RUN pnpm --filter @nexus/db db:generate
|
||||
|
||||
# Build the Next.js application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
@@ -63,7 +63,7 @@ RUN NEXTAUTH_URL="$NEXTAUTH_URL" \
|
||||
AUTH_SECRET="$AUTH_SECRET" \
|
||||
DATABASE_URL="$DATABASE_URL" \
|
||||
REDIS_URL="$REDIS_URL" \
|
||||
pnpm --filter @capakraken/web build
|
||||
pnpm --filter @nexus/web build
|
||||
|
||||
# ============================================================
|
||||
# Stage 3: Migration runner
|
||||
@@ -72,7 +72,7 @@ FROM builder AS migrator
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["pnpm", "--filter", "@capakraken/db", "db:migrate:deploy"]
|
||||
CMD ["pnpm", "--filter", "@nexus/db", "db:migrate:deploy"]
|
||||
|
||||
# ============================================================
|
||||
# Stage 4: Production runtime
|
||||
|
||||
+69
-18
@@ -1,6 +1,7 @@
|
||||
# CapaKraken – Projekt-Learnings
|
||||
# Nexus – Projekt-Learnings
|
||||
|
||||
## Format
|
||||
|
||||
**Datum | Kategorie | Problem → Lösung**
|
||||
|
||||
---
|
||||
@@ -12,11 +13,13 @@
|
||||
**Problem:** Auth.js `authorize()` callback uses `@node-rs/argon2` (native module, not Edge-compatible). Using `auth()` directly in `middleware.ts` would pull argon2 into the Edge bundle and crash.
|
||||
|
||||
**Solution — split config pattern:**
|
||||
|
||||
- `auth.config.ts` — edge-safe subset: `pages`, `session`, `cookies`, no providers, no callbacks that touch DB or argon2
|
||||
- `auth-edge.ts` — `NextAuth(authConfig)` with the lean config; used only by middleware
|
||||
- `auth.ts` — spreads `authConfig`, adds Credentials provider + argon2 callbacks + prisma session tracking
|
||||
|
||||
**Middleware wrapping:**
|
||||
|
||||
```ts
|
||||
import { auth } from "./server/auth-edge.js";
|
||||
export default auth(function middleware(request) {
|
||||
@@ -28,17 +31,19 @@ export default auth(function middleware(request) {
|
||||
```
|
||||
|
||||
**Three-layer defence:**
|
||||
|
||||
1. Middleware — server-side redirect before page renders
|
||||
2. `SessionGuard` client component — `useSession()` → `router.replace()` on SPA navigation
|
||||
3. `QueryCache` / `MutationCache` in TRPCProvider — UNAUTHORIZED tRPC errors → `window.location.replace()`
|
||||
|
||||
**Test mock pattern for middleware tests:**
|
||||
|
||||
```ts
|
||||
vi.mock("./server/auth-edge.js", () => ({
|
||||
auth: (handler) => (req) =>
|
||||
handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
||||
auth: (handler) => (req) => handler(Object.assign(req, { auth: { user: { id: "test-user" } } })),
|
||||
}));
|
||||
```
|
||||
|
||||
Needed because `vi.resetModules()` inside the helper function doesn't re-apply top-level mocks — always declare `vi.mock(...)` at file scope.
|
||||
|
||||
---
|
||||
@@ -50,12 +55,14 @@ Needed because `vi.resetModules()` inside the helper function doesn't re-apply t
|
||||
**Repo path:** `Hartmut/plANARCHY`
|
||||
|
||||
Usage example (list open issues):
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $(cat ~/.gitea-token)" \
|
||||
"https://gitea.hartmut-noerenberg.com/api/v1/repos/Hartmut/plANARCHY/issues?state=open&type=issues&limit=50"
|
||||
```
|
||||
|
||||
Close an issue with a comment:
|
||||
|
||||
```bash
|
||||
TOKEN=$(cat ~/.gitea-token)
|
||||
REPO="Hartmut/plANARCHY"
|
||||
@@ -75,18 +82,22 @@ curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/
|
||||
**Problem:** After adding a new column to `schema.prisma` and running `prisma generate` on the host, the running Docker app container still used the old Prisma client (the container's `node_modules` is a named Docker volume, isolated from the host filesystem). Queries referencing the new field (`isActive`) failed at runtime, causing tRPC procedures to return errors.
|
||||
|
||||
**Solution:** Always restart the app container after Prisma schema changes:
|
||||
|
||||
```
|
||||
docker compose --profile full restart app
|
||||
```
|
||||
|
||||
The startup script `tooling/docker/app-dev-start.sh` already runs `prisma generate` + `db:migrate:deploy` on every container start — so a restart is sufficient. No rebuild needed unless `pnpm-lock.yaml` or `Dockerfile.dev` changed.
|
||||
|
||||
**Rule:** Prisma schema change checklist:
|
||||
|
||||
1. Edit `packages/db/prisma/schema.prisma`
|
||||
2. Write migration SQL in `packages/db/prisma/migrations/<timestamp>_<name>/migration.sql`
|
||||
3. Apply migration to the running DB directly (for dev speed): `docker exec capakraken-postgres-1 psql -U capakraken -d capakraken < migration.sql`
|
||||
3. Apply migration to the running DB directly (for dev speed): `docker exec nexus-postgres-1 psql -U nexus -d nexus < migration.sql`
|
||||
4. `docker compose --profile full restart app` — regenerates Prisma client + runs migrations inside the container
|
||||
|
||||
### 2026-03-13 | Architecture | Dispo v2 chargeability calculator design
|
||||
|
||||
- Pure functions in `packages/engine/src/chargeability/calculator.ts` — no DB imports, all data passed as arguments.
|
||||
- `deriveResourceForecast()` takes SAH + assignment slices per month, returns ratio breakdown (Chg/BD/MD&I/M&O/PD&R/Absence/Unassigned).
|
||||
- Group aggregation uses FTE-weighted averages: `SUM(fte * chg) / SUM(fte)`.
|
||||
@@ -95,24 +106,28 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
||||
- React Query v5 (tRPC v11): `keepPreviousData` removed, use `placeholderData: (prev) => prev` instead.
|
||||
|
||||
### 2026-03-12 | UX/DX | Deep tRPC mutation inference in large client files
|
||||
|
||||
- `BlueprintsClient.tsx` hit `TS2589` when multiple `trpc.blueprint.*.useMutation({ onSuccess ... })` hooks lived in the same large component together with heavily inferred table/sort state.
|
||||
- Stable fix: use bare `useMutation()` hooks and move invalidation / selection cleanup into explicit `mutateAsync()` handlers. This reduces generic expansion and keeps side effects easier to follow.
|
||||
- For shared sortable tables, keep the internal sort union typed (`BlueprintSortField`) and cast only at the generic UI boundary (`SortableColumnHeader` currently exposes `string` fields).
|
||||
|
||||
### 2026-03-12 | Build | NextAuth portable export typing
|
||||
|
||||
- `export const { handlers, auth, signIn, signOut } = NextAuth(...)` triggered `TS2742` because the inferred `signIn` type captured provider internals from `@auth/core`.
|
||||
- If the server-side `signIn`/`signOut` exports are unused, export only `handlers` and `auth`. Also prefer a named `authConfig satisfies NextAuthConfig` object for clearer config typing.
|
||||
|
||||
### 2026-03-11 | Architecture | Phase 1: Application Layer Extraction
|
||||
|
||||
- Created `packages/application` with `createAllocation` and `fillPlaceholder` use-case services
|
||||
- `packages/api` router procedures now delegate to use cases; they only check permissions and emit SSE events
|
||||
- `packages/application` depends on `@capakraken/db`, `@capakraken/engine`, `@capakraken/shared`; `packages/api` depends on `@capakraken/application`
|
||||
- `packages/application` depends on `@nexus/db`, `@nexus/engine`, `@nexus/shared`; `packages/api` depends on `@nexus/application`
|
||||
- Use cases throw `TRPCError` directly (pragmatic — project only uses tRPC transport)
|
||||
- `Prisma.AllocationGetPayload<{ include: ... }>` used for precise return type in use cases
|
||||
- `exactOptionalPropertyTypes` + optional params: caller must use spread `...(val !== undefined ? { key: val } : {})` when passing zod inputs to use cases with `{ key?: T }` interfaces
|
||||
- `fillPlaceholder` returns `{ filled, decrementedPlaceholder? }` — UI `onSuccess` callbacks that don't use result data are unaffected by return shape changes
|
||||
|
||||
### 2026-03-12 | Architecture | Dashboard query extraction into application layer
|
||||
|
||||
- Moved dashboard aggregation/query logic out of `packages/api/src/router/dashboard.ts` into `packages/application/src/use-cases/dashboard/*`.
|
||||
- Keep transport concerns in the router: Zod input validation and procedure permissions remain there, while query composition and aggregation now sit in reusable application services.
|
||||
- Add small shared helpers (`calculateInclusiveDays`, bucket-key builders, average daily availability) to avoid repeating date math across dashboard slices.
|
||||
@@ -120,12 +135,14 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
||||
- While extracting `getDemand`, fix the chapter grouping bug where `resourceCount` was always `0`; it now counts distinct resources per chapter.
|
||||
|
||||
### 2026-03-12 | Architecture | Estimating foundation slice
|
||||
|
||||
- Added first-class Prisma estimating models for `Estimate`, `EstimateVersion`, assumptions, scope items, demand lines, rate cards, resource snapshots, metrics, and exports.
|
||||
- Keep this slice deliberately narrow: persistence + shared contracts + application/engine boundaries first, before any wizard/workspace UI. That avoids baking spreadsheet-shaped UI assumptions into the domain model.
|
||||
- Shared estimate enums/types/schemas now live in `@capakraken/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@capakraken/application`.
|
||||
- Shared estimate enums/types/schemas now live in `@nexus/shared`, and initial application commands/queries (`createEstimate`, `listEstimates`, `getEstimateById`) live in `@nexus/application`.
|
||||
- Added a small engine contract `summarizeEstimateDemandLines()` for aggregate financial totals so later estimate work can reuse a typed pure-function boundary instead of recomputing ad hoc in routers/components.
|
||||
|
||||
### 2026-03-11 | Architecture | Tasks 23-27: Bulk Edit, Validation, Export, Reorder
|
||||
|
||||
- Blueprint custom field validation lives in `packages/engine/src/blueprint/validator.ts` (pure function, no DB). Wire into `resource.update` by fetching the blueprint's fieldDefs and calling `validateCustomFields()` before saving. Throw `TRPCError({ code: "UNPROCESSABLE_CONTENT" })` on error.
|
||||
- Batch JSONB merge (without overwriting other keys): use `$executeRaw` with PostgreSQL's `||` JSONB merge operator: `UPDATE "Resource" SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(fields)}::jsonb WHERE id = ${id}`. Cannot use Prisma `update()` for JSONB partial merge.
|
||||
- Column drag-to-reorder: HTML5 draggable API works for lists without external libraries. Use `useRef<string | null>` to track drag source key, then `onDrop` calls the `reorder()` function.
|
||||
@@ -134,45 +151,55 @@ The startup script `tooling/docker/app-dev-start.sh` already runs `prisma genera
|
||||
- CSV export with proper escaping: wrap value in double quotes and escape internal `"` as `""` when the value contains commas, quotes, or newlines.
|
||||
|
||||
### 2026-03-11 | Architecture | JSONB filtering + useFilters hook (Tasks 20-22)
|
||||
|
||||
- Prisma JSONB path filtering: `{ customFields: { path: [key], string_contains: value } }` for text; `{ equals: bool }` for BOOLEAN; `{ array_contains: value }` for MULTI_SELECT. Build as `any[]` array and spread as `AND: cfConditions` — avoids Prisma union type issues.
|
||||
- `flatMap` with multiple return types causes TS union inference that Prisma WHERE types reject. Use a `for` loop with `push` into an explicitly typed `any[]` instead.
|
||||
- Next.js typed routes (`typedRoutes: true`) rejects dynamic URL strings even with `as unknown as RouteImpl`. Fix: cast the router itself with `useRouter() as unknown as { replace: (url: string, opts?) => void }` to escape the branded type system for dynamic URLs.
|
||||
- `useSearchParams` requires `<Suspense>` wrapping at the page level in Next.js App Router or the page will be statically rendered without search param access.
|
||||
|
||||
### 2026-03-11 | Security | Phase 0 critical fixes
|
||||
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@capakraken/db` is not enough for TS resolution.
|
||||
|
||||
- `user.create` was hashing passwords with SHA-256; `auth.ts` verifies with Argon2 → users created via admin couldn't log in. Fix: import `hash` from `@node-rs/argon2` in the router. Must also declare `@node-rs/argon2` in `packages/api/package.json` — being a dep of `@nexus/db` is not enough for TS resolution.
|
||||
- `notification.create` was `protectedProcedure` → any logged-in user could create notifications for arbitrary users. Fix: changed to `managerProcedure`.
|
||||
- `testAiConnection` always built Azure deployment URLs regardless of `aiProvider`. Fix: branch on provider, use `https://api.openai.com/v1/chat/completions` with `Authorization: Bearer` for OpenAI.
|
||||
- `@capakraken/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
||||
- `@nexus/shared` had `test:unit: vitest run` in package.json but no test files → turbo failed. Fix: remove the script (tests live only in engine/staffing).
|
||||
- `crypto.randomUUID()` in `packages/shared/src/schemas/project.schema.ts` failed typecheck because base tsconfig uses `"lib": ["ES2022"]` without DOM. Fix: add `"lib": ["ES2022", "DOM"]` in the shared package's own tsconfig.
|
||||
|
||||
### 2026-03-09 | Performance | Budget utilization showing 562% due to wrong aggregation
|
||||
|
||||
**Problem:** `getOverview` summed `allocation.project.budgetCents` once per allocation, counting project budgets multiple times for multi-resource projects.
|
||||
**Fix:** Sum `allProjects.budgetCents` (already fetched) for total budget; compute cost as `dailyCostCents × days` per allocation.
|
||||
**Fix:** Removed redundant second `db.project.findMany` call — `allProjects` already had `budgetCents`.
|
||||
|
||||
### 2026-03-09 | Performance | batchImportSkillMatrices N+1 pattern
|
||||
|
||||
**Problem:** 1 findUnique + 1 update per resource = O(2n) sequential queries.
|
||||
**Fix:** Single `findMany({ where: { eid: { in: eids } } })` + `$transaction([...updates])` = 2 round-trips total.
|
||||
|
||||
### 2026-03-09 | Performance | recomputeValueScores sequential updates
|
||||
|
||||
**Problem:** Sequential `await ctx.db.resource.update(...)` in for-loop.
|
||||
**Fix:** Build array of Prisma operations, then `$transaction(updates)` for single round-trip.
|
||||
|
||||
### 2026-03-09 | Performance | AuditLog extra findUnique in resource.create
|
||||
|
||||
**Problem:** `findUnique({ where: { email } })` to get userId already available as `ctx.dbUser?.id`.
|
||||
**Fix:** Use `ctx.dbUser?.id` directly.
|
||||
|
||||
### 2026-03-09 | UX/DX | Allocation router resource select missing lcrCents
|
||||
|
||||
`AllocationWithDetails` shared type declared `resource.lcrCents` but the Prisma select in `allocation.ts` only fetched `{ id, displayName, eid }`. The TS error appeared in `AllocationPopover.tsx` when trying to use `lcrCents`. Fix: add `lcrCents: true` to every resource select in the allocation router. Lesson: When shared types include more fields than the Prisma select, TypeScript will catch it at the usage site (not definition), which can be confusing.
|
||||
|
||||
### 2026-03-08 | UX/DX | getSkillsAnalytics returns object, not array
|
||||
|
||||
`trpc.resource.getSkillsAnalytics` returns `{ totalResources, totalSkillEntries, aggregated, categories, allChapters }` — not a flat array. Usage in `SkillTagInput` must use `data?.aggregated` to get the `{ skill, category, count }[]` list.
|
||||
|
||||
### 2026-03-08 | Focus Trap | useFocusTrap hook pattern
|
||||
|
||||
For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, call `useFocusTrap(panelRef, true)`, and add `onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}` to the inner panel div. The hook queries for all focusable elements on open and wraps Tab/Shift+Tab at the boundaries. Does NOT need to be applied to the overlay div — only the inner panel.
|
||||
|
||||
### 2026-03-05 | Setup | Prisma Client nach Schema-Änderung nicht aktuell
|
||||
|
||||
**Problem:** `ctx.db.role` war `undefined` obwohl das `Role`-Model in `schema.prisma` definiert war.
|
||||
**Lösung:** `prisma generate` regeneriert den Client, aber der Next.js Dev-Server cached die alte Version. Lösung: `.next/`-Verzeichnis löschen und Dev-Server neu starten.
|
||||
**Für künftige Projekte:** Nach Schema-Änderungen immer `rm -rf apps/web/.next` + `pnpm dev` neu starten.
|
||||
@@ -180,6 +207,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-03-05 | Architektur | Nullable FK bricht Prisma-Typen
|
||||
|
||||
**Problem:** `Allocation.resourceId` wurde nullable gemacht (für Platzhalter). Prisma typisiert dann `resource` immer als `T | null`, auch wenn man mit `isPlaceholder: false` filtert.
|
||||
**Lösung:** An allen Stellen, die `a.resource` verwenden, optional chaining (`a.resource?.id`) oder Null-Guards (`if (!a.resource) continue`) einbauen. Dashboard-Queries bekamen `isPlaceholder: false` im `where`-Clause.
|
||||
**Für künftige Projekte:** Nullable FKs immer vollständig durch den Stack propagieren – TypeScript erzwingt das ohnehin.
|
||||
@@ -187,6 +215,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-03-05 | TypeScript | exactOptionalPropertyTypes und optionale Felder
|
||||
|
||||
**Problem:** Mit `exactOptionalPropertyTypes: true` kann man `{ field: undefined }` nicht an Funktionen übergeben, die `field?: string` erwarten.
|
||||
**Lösung:** Entweder das Feld weglassen (Spread-Pattern: `{ ...(cond ? { field: val } : {}) }`) oder den Record ohne das Feld neu aufbauen (`const { field: _r, ...rest } = obj`).
|
||||
**Für künftige Projekte:** Bei optionalen Feldern immer Spread-Conditional statt explizit `undefined` setzen.
|
||||
@@ -194,19 +223,22 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-03-05 | Architektur | Prisma `include: undefined` mit exactOptionalPropertyTypes
|
||||
|
||||
**Problem:** Konditionaler `include`-Parameter (`include: condition ? {...} : undefined`) wird von Prisma mit `exactOptionalPropertyTypes` abgelehnt.
|
||||
**Lösung:** Zwei separate Query-Aufrufe mit vollständiger Typsicherheit oder Spread-Pattern auf Query-Objekt-Ebene: `ctx.db.resource.findMany({ ...baseQuery, ...(cond ? { include: {...} } : {}) })`.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-05 | Build | MCP-Server im falschen Projektpfad registriert
|
||||
|
||||
**Problem:** `claude mcp add` wurde aus einem Unterverzeichnis (`packages/db`) heraus ausgeführt. Die Server wurden unter dem Unterverzeichnis-Pfad registriert, nicht unter dem Projekt-Root.
|
||||
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/capakraken`) verschieben.
|
||||
**Lösung:** MCP-Server-Einträge manuell in `~/.claude.json` in den richtigen Projekt-Pfad (`/home/hartmut/Documents/Copilot/nexus`) verschieben.
|
||||
**Für künftige Projekte:** `claude mcp add` immer vom Projekt-Root aus ausführen.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-05 | UI | Sticky-Label-Transparenz in der Timeline
|
||||
|
||||
**Problem:** Beim horizontalen Scrollen in der Timeline schienen Balken durch die sticky linken Spalten-Labels hindurch. Ursache: `bg-amber-50/40` (40% transparent) und `dark:bg-emerald-950/60` (60% transparent im Dark Mode).
|
||||
**Lösung:** Alle sticky Label-Cells bekommen vollständig opake Hintergründe. Transparenz-Modifier (`/40`, `/60`) aus den sticky Elementen entfernt.
|
||||
**Regel:** Sticky-positionierte Elemente müssen immer opake Hintergründe haben.
|
||||
@@ -214,6 +246,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-02-xx | Architektur | tRPC-Routen-Registrierung
|
||||
|
||||
**Entscheidung:** Jeder neue Router wird in `packages/api/src/router/index.ts` registriert.
|
||||
**Muster:** `roleRouter` als `role:` registriert → Frontend nutzt `trpc.role.list.useQuery()`.
|
||||
**Achtung:** `trpc.role.list` gibt ein Array zurück, kein `{ roles: [] }` Objekt.
|
||||
@@ -221,6 +254,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-02-xx | Architektur | Zod-Schema-Tiefe und tRPC-Typen
|
||||
|
||||
**Problem:** `TS2589: Type instantiation is excessively deep` bei `BlueprintFieldEditor.tsx` – tRPC leitet Typen rekursiv ab.
|
||||
**Lösung:** Ist ein bekannter Pre-existing-Error durch zu tiefe Zod-Schema-Verschachtelung. Separate Mutations wie `updateRolePresets` (statt in `update` einzubauen) umgehen das Problem.
|
||||
**Für künftige Projekte:** Bei tRPC-Schemas `.refine()` nie vor `.partial()` anwenden; komplexe Schemas in separate Procedures auslagern.
|
||||
@@ -228,8 +262,10 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-02-xx | Architektur | Prisma-Enum vs. Shared-Enum
|
||||
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@capakraken/shared`-Enums kompatibel sind.
|
||||
|
||||
**Problem:** Prisma generiert eigene Enum-Typen, die TypeScript-seitig nicht mit den `@nexus/shared`-Enums kompatibel sind.
|
||||
**Lösung:** An Client-Grenzen `as unknown as SharedType` casten:
|
||||
|
||||
- `project as unknown as Project`
|
||||
- `form.orderType as unknown as OrderType`
|
||||
- `resource.skills as unknown as SkillEntry[]`
|
||||
@@ -237,6 +273,7 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
---
|
||||
|
||||
### 2026-02-xx | Architektur | SSE statt WebSockets
|
||||
|
||||
**Entscheidung:** Server-Sent Events (SSE) für Realtime-Updates, kein WebSocket.
|
||||
**Begründung:** Simpler zu implementieren, keine Bidirektionalität nötig, funktioniert hinter Standard-HTTP-Proxies.
|
||||
**Trade-off:** Nur Server→Client-Push; Client-initiierte Updates laufen weiter über tRPC-Mutations.
|
||||
@@ -247,20 +284,21 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
|
||||
**Problem:** `TimelineView.tsx` wuchs auf 1863 Zeilen – schwer wartbar, kaum testbar.
|
||||
**Lösung:** Schrittweise Extraktion:
|
||||
|
||||
1. Konstanten → `timelineConstants.ts` (keine State-Abhängigkeit)
|
||||
2. Heatmap-Utilities → `heatmapUtils.ts`
|
||||
3. Layout-Berechnungen → `useTimelineLayout.tsx` Hook
|
||||
4. Header-JSX → `TimelineHeader.tsx`
|
||||
5. Toolbar-JSX → `TimelineToolbar.tsx`
|
||||
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
||||
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
||||
**Ergebnis:** TimelineView.tsx von 1863 → 1597 Zeilen, 0 neue TS-Fehler.
|
||||
**Nicht extrahiert:** Render-Funktionen (renderAllocBlocks etc.) – diese schließen über zu viele State-Variablen und brauchen eine Context-Lösung in einem separaten Schritt.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-06 | Architektur | Redis Pub/Sub für SSE
|
||||
|
||||
**Problem:** SSE Event-Bus war ein In-Memory-Singleton, funktioniert nicht bei mehreren Server-Instanzen.
|
||||
**Lösung:** `ioredis` in `@capakraken/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `capakraken:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
||||
**Lösung:** `ioredis` in `@nexus/api` hinzugefügt. Publisher schreibt Events in Redis-Channel `nexus:sse`, Subscriber auf jeder Instanz empfängt und liefert lokal aus. Graceful Degradation: bei Redis-Ausfall weiterhin lokale Delivery.
|
||||
**Import-Pattern:** `import { Redis } from "ioredis"` (named export, nicht default) – notwendig mit `moduleResolution: NodeNext` + ioredis v5.
|
||||
**Offene Frage:** In Dev-Umgebung reicht lokale Delivery; Redis läuft auf Port 6380 via Docker Compose.
|
||||
|
||||
@@ -340,10 +378,12 @@ For modal focus trapping: create a `panelRef = useRef<HTMLDivElement>(null)`, ca
|
||||
**Problem:** `prisma.user.upsert({ update: {} })` lässt bestehende User-Records unverändert. Nach einer Passwort-Hash-Migration (SHA-256 → Argon2) behielten die Seed-User ihre alten SHA-256-Hashes. `verify(sha256hash, password)` warf eine Exception ("Invalid hashed password: password hash string missing field"), was NextAuth als `error=Configuration` surfacete — Login unmöglich.
|
||||
**Symptom:** DB `passwordHash` hatte Länge 64 (SHA-256 Hex), kein `$argon2id$`-Prefix.
|
||||
**Lösung:** Im Seed alle drei User-Hash-Variablen vorher awaiten und in **beide** Blöcke (`create` und `update`) einsetzen:
|
||||
|
||||
```typescript
|
||||
const adminHash = await hash("admin123");
|
||||
prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: { ..., passwordHash: adminHash } });
|
||||
```
|
||||
|
||||
**Für künftige Projekte:** Prisma `upsert` mit `update: {}` stellt sicher, dass ein Record existiert, updated ihn aber nie. Bei Auth-Migrations immer `update`-Block befüllen.
|
||||
|
||||
---
|
||||
@@ -383,18 +423,20 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
**Problem:** `useSession()` aus `next-auth/react` wirft einen Runtime Error ("useSession must be wrapped in a SessionProvider"), wenn kein `<SessionProvider>` im React-Baum existiert. Der Agent nutzte `useSession()` in `AppShell.tsx` und `usePermissions.ts`, obwohl das Root-Layout keinen `SessionProvider` enthielt.
|
||||
**Symptom:** 500 Internal Server Error auf allen App-Seiten nach Login — dieselbe Oberfläche wie beim Stale-Prisma-Client-Bug.
|
||||
**Lösung (zweistufig):**
|
||||
|
||||
1. `SessionProvider` einmalig in `TRPCProvider` (`apps/web/src/lib/trpc/provider.tsx`) einbauen — zentraler Ort, funktioniert für alle `useSession()`-Aufrufe in der App.
|
||||
2. `AppShell.tsx`: `useSession()` entfernt, `userRole` stattdessen als Prop vom Server-Component-Layout durchgereicht (sauberer, kein Client-Context nötig für diesen Fall).
|
||||
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
||||
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
||||
**Regel:** Vor `useSession()` immer prüfen ob `SessionProvider` im Baum liegt. In Next.js App Router: `SessionProvider` gehört in ein Client-Component (z.B. Provider-Wrapper), nicht direkt ins Server-Layout.
|
||||
**Für künftige Projekte:** Wer `next-auth/react`-Hooks nutzt, muss sicherstellen dass `SessionProvider` genau einmal in `apps/web/src/lib/trpc/provider.tsx` oder einem dedizierten `Providers.tsx` Client-Component vorhanden ist.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-06 | Architektur | Granulares RBAC-System: Permission-Override-Muster
|
||||
|
||||
**Kontext:** CapaKraken hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
||||
**Kontext:** Nexus hatte 3 hartkodierte Procedure-Levels (protectedProcedure → managerProcedure → adminProcedure) ohne Granularität. Ziel: neue Rolle CONTROLLER + individuelle Permission-Overrides pro User.
|
||||
|
||||
**Lösung:** Zweigeteiltes System:
|
||||
|
||||
1. **`ROLE_DEFAULT_PERMISSIONS`** — statische Lookup-Tabelle: jede SystemRole hat eine Default-Menge an PermissionKeys.
|
||||
2. **`permissionOverrides: Json?`** auf dem User-Model (war bereits vorhanden, aber ungenutzt) — `{ granted: [], denied: [], chapterIds: [] }` für individuelle Anpassungen.
|
||||
3. **`resolvePermissions(role, overrides)`** — gibt `Set<PermissionKey>` zurück, wendet grants/denials auf die Rolle-Defaults an.
|
||||
@@ -421,6 +463,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
---
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- [x] Wie skalieren wir den SSE Event-Bus bei mehreren Server-Instanzen? → P7.1 umgesetzt (Redis Pub/Sub)
|
||||
- [x] Playwright E2E-Tests sind eingerichtet aber noch nicht befüllt → P5.4 umgesetzt (auth, resources, timeline, projects)
|
||||
- [x] P7.2 Touch-Support → umgesetzt
|
||||
@@ -430,6 +473,7 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
---
|
||||
|
||||
### 2026-03-07 | Architektur | Resource Value Score – kontext-freie Composite-Metrik
|
||||
|
||||
**Problem:** Staffing-Scorer in `skill-matcher.ts` ist projekt-kontextabhängig (requiredSkills, budget). Ein persistenter "Price/Quality"-Score pro Resource brauchte eine neue, kontext-freie Berechnung.
|
||||
**Lösung:** Neues Pure-Function-Modul `packages/staffing/src/value-scorer.ts` mit `computeValueScore()`. 5 Dimensionen (skillDepth, skillBreadth, costEfficiency, chargeability, experience) werden gewichtet summiert. Score wird asynchron via `recomputeValueScores` in DB persistiert (nicht live berechnet).
|
||||
**Pattern:** JSONB-Breakdown (`valueScoreBreakdown`) direkt auf Resource speichern → kein N+1 bei List-Queries. Sichtbarkeit per `scoreVisibleRoles` in SystemSettings konfigurierbar.
|
||||
@@ -439,14 +483,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
---
|
||||
|
||||
### 2026-03-07 | DevOps | Dev-Server nach prisma generate immer neu starten
|
||||
|
||||
**Problem:** Nach `prisma generate` + `rm -rf .next/` lieferte der laufende Next.js Dev-Server für ALLE Seiten HTTP 500 — kein tRPC-Fehler, sondern ein globaler Absturz.
|
||||
**Ursache:** Node.js cached geladene Module im Speicher. Der laufende Prozess hatte den alten Prisma-Client geladen; nach `prisma generate` überschrieb das neue Client-JS die Dateien in `node_modules`, aber der Prozess nutzte noch den alten In-Memory-Cache. Zusammen mit einem geleerten `.next/`-Verzeichnis führte dies zu einem inkonsistenten Zustand.
|
||||
**Lösung:** Dev-Server nach jeder `prisma generate`-Ausführung neu starten (`kill` + `pnpm dev`).
|
||||
**Merkregel:** `db:push` → `.next/` löschen → **Dev-Server neu starten** (immer alle drei Schritte zusammen).
|
||||
|
||||
### 2026-03-11 | UI/UX | Universal Table Sorting + Drag-and-Drop Row Reordering + Persistent View State
|
||||
|
||||
**Problem:** Column sort was only on the Resources page; no drag-to-reorder rows; view state (sort + row order) not persisted per user.
|
||||
**Lösung:**
|
||||
|
||||
- **`useTableSort` erweitert** mit `options.initialField/Dir` + `options.onSortChange` callback. `isFirstRender` ref verhindert, dass die erste Render-Runde einen save auslöst.
|
||||
- **`useViewPrefs(view)`** neuer Hook: liest/schreibt `viewprefs_<view>` localStorage (getrennt von `colvis_<view>` des bestehenden `useColumnConfig`). Server-sync via debounced (600ms) `trpc.user.setColumnPreferences` mit merge-Logik (null=clear, undefined=keep, value=set).
|
||||
- **`useRowOrder`** neuer Hook: gibt `orderedRows` zurück. Wenn `activeSortField !== null` → sort gewinnt, rowOrder wird ignoriert. Drag aktiviert manuelle Reihenfolge + resettet sort.
|
||||
@@ -457,15 +504,17 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
- **DraggableTableRow `onDrop` Semantik-Bug:** Initial wurde `onDrop(id)` mit der Ziel-Row-ID aufgerufen → `reorder(project.id, project.id)` war ein No-Op. Fix: `onDrop(dragRef.current)` übergibt die GEZOGENE ID; Prop-Vertrag entsprechend angepasst.
|
||||
|
||||
### 2026-03-12 | Dashboard | Shared widget contracts + persisted layout normalization
|
||||
|
||||
**Problem:** Dashboard layout was persisted as unchecked JSON in `User.dashboardLayout`. The web layer still rendered widgets via a manual switch, and `AddWidgetModal` created `y: Infinity`, which became `null` after JSON serialization and left persisted layouts invalid.
|
||||
**Lösung:**
|
||||
|
||||
- **Canonical shared contract:** added `packages/shared/src/types/dashboard.ts` for widget types, catalog metadata, persisted layout shape, and default config values.
|
||||
- **Schema + migration path:** added `packages/shared/src/schemas/dashboard.schema.ts` with `normalizeDashboardLayout()`, `createDashboardWidget()`, `createDefaultDashboardLayout()`, and per-widget config schemas. Invalid persisted values are repaired on load/save instead of crashing or drifting.
|
||||
- **API normalization:** `packages/api/src/router/user.ts` now validates `saveDashboardLayout` input through the shared dashboard schema and normalizes DB reads before returning them.
|
||||
- **Registry-driven rendering:** `DashboardClient` now renders widgets from a registry in `widget-registry.ts` rather than a hardcoded switch. Widget metadata is sourced from the shared catalog.
|
||||
- **Bug fix:** new widgets are now appended at `getNextDashboardWidgetY(existingWidgets)` rather than using `Infinity`, so persisted layouts remain JSON-safe.
|
||||
- **Regression coverage:** added `packages/shared/src/__tests__/dashboard-layout.test.ts` for default fallback, invalid-coordinate repair, duplicate-ID normalization, and next-row calculation.
|
||||
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
||||
**TypeScript note:** `exactOptionalPropertyTypes` required building option objects with conditional spreads rather than passing `{ title: undefined }` into helper APIs. This matters for any future shared normalizer helpers.
|
||||
|
||||
### 2026-04-01 | Architecture Decision | API Keys — no implementation without explicit product decision
|
||||
|
||||
@@ -474,11 +523,13 @@ prisma.user.upsert({ where: ..., update: { passwordHash: adminHash }, create: {
|
||||
**Decision: No code is written until the product decision is made.**
|
||||
|
||||
The core trade-off is:
|
||||
|
||||
- **Short-lived JWTs (current approach):** Zero DB footprint, automatic expiry, no revocation surface. Works well for a single-tenant SaaS where all clients are browser sessions. No additional attack surface.
|
||||
- **Long-lived API keys stored in DB:** Enables CLI tooling, CI/CD pipelines, and machine-to-machine workflows. Requires: secure token generation (crypto.randomBytes, bcrypt hash stored, raw key shown once), per-key scopes, revocation endpoint, key rotation policy, audit log for key usage. Significantly larger attack surface and ops burden.
|
||||
- **Short-lived API tokens (OAuth-style):** Suitable if CapaKraken exposes a public API. Over-engineered for an internal tool with no current integration story.
|
||||
- **Short-lived API tokens (OAuth-style):** Suitable if Nexus exposes a public API. Over-engineered for an internal tool with no current integration story.
|
||||
|
||||
**Engineering guidance for when the decision is made:**
|
||||
|
||||
1. Store only the SHA-256 or bcrypt hash of the key, never the raw token.
|
||||
2. Enforce per-key scopes aligned with the `SystemRole` permission model.
|
||||
3. Add `keyUsedAt` tracking and hard expiry via TTL field on the DB row.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/dashboard-dark.jpeg" alt="CapaKraken Dashboard" width="100%" />
|
||||
<img src="docs/screenshots/dashboard-dark.jpeg" alt="Nexus Dashboard" width="100%" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">CapaKraken</h1>
|
||||
<h1 align="center">Nexus</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Resource & Capacity Planning for 3D Production Studios</strong><br/>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## About
|
||||
|
||||
CapaKraken is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
||||
Nexus is a full-stack resource planning and project staffing application built for 3D production environments -- VFX studios, animation houses, and automotive visualization teams. It replaces spreadsheet-based capacity planning with a real-time, multi-user web application that provides a single source of truth for who is working on what, when, and at what cost.
|
||||
|
||||
The application was designed from the ground up for the unique challenges of creative production: fluctuating team sizes, overlapping project phases, mixed chargeability models (client-billable vs. internal vs. BD), complex holiday calendars across multiple countries, and the need to forecast resource availability months in advance.
|
||||
|
||||
@@ -39,7 +39,7 @@ The application was designed from the ground up for the unique challenges of cre
|
||||
<img src="docs/screenshots/timeline-resource-dark.jpeg" alt="Timeline - Resource View" width="100%" />
|
||||
</p>
|
||||
|
||||
The timeline is the centerpiece of CapaKraken. It provides a visual, interactive view of all resource allocations across projects.
|
||||
The timeline is the centerpiece of Nexus. It provides a visual, interactive view of all resource allocations across projects.
|
||||
|
||||
- **Resource View** -- see all allocations for each person, with color-coded project bars stacked in sub-lanes when they overlap
|
||||
- **Project View** -- flip the perspective to see all resources assigned to each project
|
||||
@@ -97,18 +97,18 @@ A structured list view of all allocations with:
|
||||
|
||||
Each user gets a personal dashboard they can customize with drag-and-drop widgets:
|
||||
|
||||
| Widget | Description |
|
||||
|--------|-------------|
|
||||
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
||||
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
||||
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
||||
| **Project Overview** | All projects with cost, person days, and status badges |
|
||||
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
||||
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
||||
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
||||
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
||||
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
||||
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
||||
| Widget | Description |
|
||||
| -------------------------- | --------------------------------------------------------------------------------- |
|
||||
| **Overview Stats** | Total resources, active projects, allocations, and budget utilization at a glance |
|
||||
| **My Projects** | Quick access to projects where the current user is assigned or responsible |
|
||||
| **Resource Table** | Filterable EID list with utilization percentages and chargeability indicators |
|
||||
| **Project Overview** | All projects with cost, person days, and status badges |
|
||||
| **Peak Times** | Bar chart showing booked hours vs. capacity over time, with department breakdown |
|
||||
| **Demand View** | Staffing demand vs. supply by project, with unfilled headcount tracking |
|
||||
| **Chargeability Overview** | Leaderboard of resources ranked by chargeability score |
|
||||
| **Budget Forecast** | Budget burn rate and projected cost per active project |
|
||||
| **Skill Gap Analysis** | Top skill shortages comparing open demand against available supply |
|
||||
| **Project Health** | Composite health score per project (budget, staffing, timeline) |
|
||||
|
||||
Widgets are resizable, and the layout persists per user. An **Add Widget** catalog lets users browse available widgets with descriptions and default sizes.
|
||||
|
||||
@@ -172,23 +172,23 @@ A full project estimation workflow:
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
||||
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
||||
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
||||
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
||||
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
||||
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
||||
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
||||
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
||||
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
||||
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
||||
| Layer | Technology | Purpose |
|
||||
| -------------------- | --------------------------------- | -------------------------------------------------------- |
|
||||
| **Frontend** | Next.js 15 (App Router), React 19 | Server components, streaming, file-based routing |
|
||||
| **Styling** | Tailwind CSS v4 | Utility-first CSS with custom design tokens |
|
||||
| **API** | tRPC v11 | End-to-end type-safe RPC between client and server |
|
||||
| **Database** | PostgreSQL 16 via Prisma ORM | Relational data with JSONB for dynamic fields |
|
||||
| **Auth** | Auth.js v5 | Session management, Argon2 passwords, TOTP MFA |
|
||||
| **Realtime** | SSE + Redis pub/sub | Live updates without WebSocket complexity |
|
||||
| **AI** | Azure OpenAI / Gemini | Staffing suggestions, skill profile generation |
|
||||
| **Monorepo** | pnpm workspaces + Turborepo | Incremental builds, shared configs, dependency isolation |
|
||||
| **Testing** | Vitest + Playwright | Unit tests (engine, shared) and E2E browser tests |
|
||||
| **Containerization** | Docker Compose | Dev and production stacks with health checks |
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
capakraken/
|
||||
nexus/
|
||||
|
|
||||
+-- apps/
|
||||
| +-- web/ Next.js 15 application (frontend + API routes)
|
||||
@@ -263,18 +263,18 @@ capakraken/
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Minimum Version | Check |
|
||||
|-------------|----------------|-------|
|
||||
| **Node.js** | 20.x | `node --version` |
|
||||
| **pnpm** | 9.x | `pnpm --version` |
|
||||
| **Docker** | 24+ | `docker --version` |
|
||||
| **Docker Compose** | v2 | `docker compose version` |
|
||||
| Requirement | Minimum Version | Check |
|
||||
| ------------------ | --------------- | ------------------------ |
|
||||
| **Node.js** | 20.x | `node --version` |
|
||||
| **pnpm** | 9.x | `pnpm --version` |
|
||||
| **Docker** | 24+ | `docker --version` |
|
||||
| **Docker Compose** | v2 | `docker compose version` |
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git capakraken
|
||||
cd capakraken
|
||||
git clone https://gitea.hartmut-noerenberg.com/Hartmut/plANARCHY.git nexus
|
||||
cd nexus
|
||||
```
|
||||
|
||||
Create your environment file:
|
||||
@@ -315,13 +315,13 @@ This single command will:
|
||||
You'll see output like:
|
||||
|
||||
```
|
||||
Starting CapaKraken...
|
||||
Starting Nexus...
|
||||
Starting PostgreSQL + Redis...
|
||||
Waiting for PostgreSQL...
|
||||
Starting app container on port 3100...
|
||||
Waiting for server (up to 90s)...
|
||||
|
||||
CapaKraken is running!
|
||||
Nexus is running!
|
||||
{
|
||||
"status": "ok",
|
||||
"database": "connected",
|
||||
@@ -372,13 +372,13 @@ This populates the database with sample clients, projects, resources, allocation
|
||||
|
||||
When running with Docker Compose, the following services are available:
|
||||
|
||||
| Service | URL | Purpose |
|
||||
|---------|-----|---------|
|
||||
| **CapaKraken App** | [localhost:3100](http://localhost:3100) | Main application |
|
||||
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
||||
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
||||
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `capakraken`, db: `capakraken`) |
|
||||
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
||||
| Service | URL | Purpose |
|
||||
| -------------- | --------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **Nexus App** | [localhost:3100](http://localhost:3100) | Main application |
|
||||
| **MailHog** | [localhost:8025](http://localhost:8025) | Email testing UI -- catches all outgoing emails (invitations, password resets, notifications) |
|
||||
| **pgAdmin** | [localhost:5050](http://localhost:5050) | Visual database administration |
|
||||
| **PostgreSQL** | `localhost:5433` | Direct database access (user: `nexus`, db: `nexus`) |
|
||||
| **Redis** | `localhost:6380` | Cache, rate limiting, and SSE pub/sub |
|
||||
|
||||
---
|
||||
|
||||
@@ -386,50 +386,50 @@ When running with Docker Compose, the following services are available:
|
||||
|
||||
### Application Lifecycle
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
||||
| `bash scripts/stop.sh` | Stop all services gracefully |
|
||||
| `bash scripts/restart.sh` | Full stop + start cycle |
|
||||
| Command | Description |
|
||||
| ------------------------- | ------------------------------------------- |
|
||||
| `bash scripts/start.sh` | Start all services (PostgreSQL, Redis, app) |
|
||||
| `bash scripts/stop.sh` | Stop all services gracefully |
|
||||
| `bash scripts/restart.sh` | Full stop + start cycle |
|
||||
|
||||
### Development
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
||||
| `pnpm build` | Production build (standalone output) |
|
||||
| `pnpm lint` | Run ESLint across all packages |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| `pnpm test:unit` | Run unit tests via Vitest |
|
||||
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
||||
| `pnpm typecheck` | TypeScript type checking across all packages |
|
||||
| Command | Description |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `pnpm dev` | Start Next.js dev server with hot reload (host-native) |
|
||||
| `pnpm build` | Production build (standalone output) |
|
||||
| `pnpm lint` | Run ESLint across all packages |
|
||||
| `pnpm format` | Format all files with Prettier |
|
||||
| `pnpm test:unit` | Run unit tests via Vitest |
|
||||
| `pnpm test:e2e` | Run end-to-end tests via Playwright |
|
||||
| `pnpm typecheck` | TypeScript type checking across all packages |
|
||||
| `pnpm check:architecture` | Verify architecture guardrails (import boundaries, etc.) |
|
||||
|
||||
### Database
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
||||
| `pnpm db:migrate` | Create and apply new migrations |
|
||||
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
||||
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
||||
| `pnpm db:seed` | Seed the database with demo data |
|
||||
| `pnpm db:doctor` | Run health checks on database state |
|
||||
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
||||
| `pnpm db:seed:import` | Import a previously exported seed file |
|
||||
| Command | Description |
|
||||
| --------------------- | ------------------------------------------------ |
|
||||
| `pnpm db:generate` | Regenerate Prisma client after schema changes |
|
||||
| `pnpm db:migrate` | Create and apply new migrations |
|
||||
| `pnpm db:push` | Push schema changes directly (no migration file) |
|
||||
| `pnpm db:studio` | Open Prisma Studio (visual data browser) |
|
||||
| `pnpm db:seed` | Seed the database with demo data |
|
||||
| `pnpm db:doctor` | Run health checks on database state |
|
||||
| `pnpm db:seed:export` | Export current DB state as a seed file |
|
||||
| `pnpm db:seed:import` | Import a previously exported seed file |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
CapaKraken ships with a production-ready Docker Compose stack and deployment automation.
|
||||
Nexus ships with a production-ready Docker Compose stack and deployment automation.
|
||||
|
||||
### Quick Deploy
|
||||
|
||||
```bash
|
||||
# Configure required secrets
|
||||
export APP_IMAGE=ghcr.io/your-org/capakraken-app:latest
|
||||
export MIGRATOR_IMAGE=ghcr.io/your-org/capakraken-migrator:latest
|
||||
export APP_IMAGE=ghcr.io/your-org/nexus-app:latest
|
||||
export MIGRATOR_IMAGE=ghcr.io/your-org/nexus-migrator:latest
|
||||
export POSTGRES_PASSWORD=$(openssl rand -hex 32)
|
||||
export REDIS_PASSWORD=$(openssl rand -hex 32)
|
||||
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
||||
@@ -454,35 +454,35 @@ bash tooling/deploy/deploy-compose.sh production
|
||||
|
||||
See [`.env.example`](.env.example) for the complete reference with inline documentation. Summary of key variables:
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
||||
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
||||
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
||||
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
||||
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
||||
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||
| `SMTP_FROM` | No | `noreply@capakraken.dev` | Sender address for outgoing emails |
|
||||
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
||||
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
||||
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
||||
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
||||
| Variable | Required | Default | Description |
|
||||
| ---------------------- | -------- | -------------------- | ----------------------------------------------- |
|
||||
| `NEXTAUTH_URL` | Yes | -- | Public URL of the application |
|
||||
| `NEXTAUTH_SECRET` | Yes | -- | Secret for JWT signing and session encryption |
|
||||
| `DATABASE_URL` | Yes | `localhost:5433` | PostgreSQL connection string |
|
||||
| `REDIS_PASSWORD` | Prod | -- | Redis authentication password |
|
||||
| `REDIS_URL` | No | `redis://redis:6379` | Redis connection (auto-configured in Docker) |
|
||||
| `SMTP_HOST` | No | -- | SMTP server for email delivery |
|
||||
| `SMTP_PORT` | No | `587` | SMTP port |
|
||||
| `SMTP_FROM` | No | `noreply@nexus.dev` | Sender address for outgoing emails |
|
||||
| `AZURE_OPENAI_API_KEY` | No | -- | Enables AI-assisted staffing features |
|
||||
| `GEMINI_API_KEY` | No | -- | Alternative AI provider |
|
||||
| `LOG_LEVEL` | No | `info` | Logging verbosity (trace/debug/info/warn/error) |
|
||||
| `CRON_SECRET` | No | -- | Authenticates scheduled job endpoints |
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
| Principle | Implementation |
|
||||
|-----------|---------------|
|
||||
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
||||
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
||||
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
||||
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
||||
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
||||
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
||||
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
||||
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
||||
| Principle | Implementation |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Money as integer cents** | All monetary values stored and calculated in cents to eliminate floating-point drift |
|
||||
| **Strict TypeScript** | No `any` types, strict null checks, explicit Prisma casts at package boundaries |
|
||||
| **Domain-driven packages** | Each bounded context (estimating, chargeability, staffing) lives in its own package with clear exports |
|
||||
| **Pure engine logic** | Calculation packages have zero I/O dependencies -- they take data in and return results |
|
||||
| **Real-time by default** | SSE pushes changes to all clients via Redis pub/sub; no polling |
|
||||
| **Theme-aware UI** | CSS variable-based surface system with configurable accent colors and full dark mode |
|
||||
| **Defensive data handling** | Nullable foreign keys handled explicitly; Prisma enums and JSONB cast at boundaries |
|
||||
| **No speculative abstractions** | Build what's needed now; three similar lines beat a premature abstraction |
|
||||
|
||||
---
|
||||
|
||||
@@ -494,7 +494,7 @@ See [`.env.example`](.env.example) for the complete reference with inline docume
|
||||
4. Run quality gates before submitting:
|
||||
```bash
|
||||
pnpm test:unit
|
||||
pnpm --filter @capakraken/web exec tsc --noEmit
|
||||
pnpm --filter @nexus/web exec tsc --noEmit
|
||||
pnpm lint
|
||||
pnpm check:architecture
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from "./a11y-fixture.js";
|
||||
test.describe("Accessibility (axe-core)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 });
|
||||
|
||||
+17
-14
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Admin Pages", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -12,39 +12,42 @@ test.describe("Admin Pages", () => {
|
||||
test("settings page loads", async ({ page }) => {
|
||||
await page.goto("/admin/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: /System Settings/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("users page loads with user list", async ({ page }) => {
|
||||
await page.goto("/admin/users");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: /User Management/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Should show a table with at least the admin user
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=admin@capakraken.dev")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=admin@nexus.dev")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("roles page loads", async ({ page }) => {
|
||||
await page.goto("/roles");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1").filter({ hasText: /Roles/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1").filter({ hasText: /Roles/i })).toBeVisible({ timeout: 10000 });
|
||||
// Should show table or list of roles
|
||||
await expect(
|
||||
page.locator("table").or(page.locator("text=No roles")),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("table").or(page.locator("text=No roles"))).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("blueprints page loads", async ({ page }) => {
|
||||
await page.goto("/admin/blueprints");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1").filter({ hasText: /Blueprints/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1").filter({ hasText: /Blueprints/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Should show blueprint cards or list from seed data
|
||||
await expect(
|
||||
page.locator("table")
|
||||
page
|
||||
.locator("table")
|
||||
.or(page.locator("text=3D Content Production"))
|
||||
.or(page.locator("text=No blueprints")),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -40,24 +40,28 @@ async function signIn(page: Page, email: string, password: string) {
|
||||
test.describe("Allocations", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await freezeBrowserTime(page);
|
||||
await signIn(page, "admin@capakraken.dev", "admin123");
|
||||
await signIn(page, "admin@nexus.dev", "admin123");
|
||||
await page.goto("/allocations");
|
||||
});
|
||||
|
||||
test("seeded assignments stay visibly rendered on first load", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("allocations-table")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("allocations-empty-state")).toHaveCount(0);
|
||||
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("allocation-group-header").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByTestId("allocation-row").first()).toBeVisible({ timeout: 10000 });
|
||||
expect(await page.getByTestId("allocation-row").count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("explicitly restrictive filters show a visible empty state and can be reset", async ({ page }) => {
|
||||
test("explicitly restrictive filters show a visible empty state and can be reset", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const projectFilter = page.getByPlaceholder("Filter by project…");
|
||||
@@ -83,21 +87,23 @@ test.describe("Allocations", () => {
|
||||
await expect(newBtn).toBeVisible({ timeout: 10000 });
|
||||
await newBtn.click();
|
||||
await expect(page.getByTestId("allocation-modal")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("heading", { name: /New (Assignment|Open Demand)/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /New (Assignment|Open Demand)/i }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("filter by status works", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Look for status filter chips or dropdown
|
||||
const statusFilter = page.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i }).first();
|
||||
const statusFilter = page
|
||||
.locator("button", { hasText: /Proposed|Confirmed|Active|Status/i })
|
||||
.first();
|
||||
if ((await statusFilter.count()) > 0) {
|
||||
await statusFilter.click();
|
||||
await page.waitForTimeout(300);
|
||||
// After clicking a status filter, the page should still show the table
|
||||
await expect(
|
||||
page.locator("table").or(page.locator("text=No allocations")),
|
||||
).toBeVisible();
|
||||
await expect(page.locator("table").or(page.locator("text=No allocations"))).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,17 +114,17 @@ test.describe("Allocations", () => {
|
||||
await colToggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
// A panel or dropdown with column checkboxes should appear
|
||||
await expect(
|
||||
page.locator("input[type='checkbox']").first(),
|
||||
).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator("input[type='checkbox']").first()).toBeVisible({ timeout: 3000 });
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
});
|
||||
|
||||
test("viewer sees a visible access error instead of an empty allocations page", async ({ browser }) => {
|
||||
test("viewer sees a visible access error instead of an empty allocations page", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const page = await browser.newPage();
|
||||
await freezeBrowserTime(page);
|
||||
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
||||
await signIn(page, "viewer@nexus.dev", "viewer123");
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -71,8 +71,8 @@ test.describe("Analytics / Insights", () => {
|
||||
test("insights page loads without errors", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Page should render some heading or content area — not a hard 404
|
||||
await expect(
|
||||
page.locator("h1").or(page.locator("main")).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1").or(page.locator("main")).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ADMIN_EMAIL = "admin@capakraken.dev";
|
||||
const ADMIN_EMAIL = "admin@nexus.dev";
|
||||
const ADMIN_PASSWORD = "admin123";
|
||||
const CURRENT_CONVERSATION_ID = "assistant-e2e-current";
|
||||
const DB_WORKDIR = resolve(process.cwd(), "../../packages/db");
|
||||
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((conversationId) => {
|
||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
||||
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
|
||||
}, CURRENT_CONVERSATION_ID);
|
||||
|
||||
runDb(`
|
||||
@@ -159,7 +159,9 @@ test.describe("Assistant approvals", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({ page }) => {
|
||||
test("renders the pending approval inbox and handles cross-conversation actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suffix = Date.now();
|
||||
const currentClientName = `E2E Approval Client Current ${suffix}`;
|
||||
const otherClientName = `E2E Approval Client Other ${suffix}`;
|
||||
@@ -210,14 +212,22 @@ test.describe("Assistant approvals", () => {
|
||||
await expect(page.getByText(currentApproval.summary)).toBeVisible();
|
||||
await expect(page.getByText(otherApproval.summary)).toBeVisible();
|
||||
|
||||
const currentCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]').first();
|
||||
const otherCard = page.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]').first();
|
||||
const currentCard = page
|
||||
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="current"]')
|
||||
.first();
|
||||
const otherCard = page
|
||||
.locator('[data-testid="assistant-approval-card"][data-conversation-scope="other"]')
|
||||
.first();
|
||||
await expect(currentCard).toContainText("This chat");
|
||||
await expect(otherCard).toContainText("Other chat");
|
||||
|
||||
await otherCard.getByTestId("assistant-approval-cancel").click();
|
||||
await expect(page.getByText(`Aktion verworfen: ${otherApproval.summary}`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`)).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-testid="assistant-approval-card"][data-approval-id="${otherApproval.id}"]`,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe("Authentication", () => {
|
||||
|
||||
test("admin can sign in", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -16,9 +16,7 @@ test.describe("Bench Board", () => {
|
||||
|
||||
test("bench board page loads with heading", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1", { hasText: "Bench Board" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: "Bench Board" })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("date range filter inputs are visible", async ({ page }) => {
|
||||
@@ -32,7 +30,8 @@ test.describe("Bench Board", () => {
|
||||
test("shows bench results or no-resources empty state", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("table")
|
||||
page
|
||||
.locator("table")
|
||||
.or(page.locator("text=No resources on bench"))
|
||||
.or(page.locator("text=No results"))
|
||||
.first(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Dashboard", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -31,7 +31,9 @@ test.describe("Dashboard", () => {
|
||||
const addBtn = page.locator("button", { hasText: /Add Widget/i });
|
||||
if ((await addBtn.count()) > 0) {
|
||||
await addBtn.click();
|
||||
await expect(page.locator("text=Add Widget").or(page.locator("text=Available Widgets"))).toBeVisible();
|
||||
await expect(
|
||||
page.locator("text=Add Widget").or(page.locator("text=Available Widgets")),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||
// Error message visible
|
||||
await expect(
|
||||
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
||||
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
|
||||
|
||||
// At least one user row should be visible
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
||||
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("text=No users found")).toHaveCount(0);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
||||
* - Cleans up the test user in afterAll.
|
||||
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
||||
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
|
||||
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
|
||||
*/
|
||||
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
||||
|
||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
||||
type TrpcResult = {
|
||||
result?: { data?: unknown };
|
||||
error?: { data?: { code?: string }; message?: string };
|
||||
};
|
||||
|
||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcMutation(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
||||
);
|
||||
}
|
||||
|
||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcQuery(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Default layout should show at least the stat-cards widget
|
||||
// (from createDefaultDashboardLayout in useDashboardLayout)
|
||||
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
|
||||
await expect(
|
||||
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
|
||||
).toBeVisible({
|
||||
timeout: 8000,
|
||||
});
|
||||
});
|
||||
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
|
||||
await navigateToDashboard(page);
|
||||
|
||||
// Open modal
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Verify modal is open
|
||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify widget entries are visible in the modal
|
||||
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
||||
const widgetButtons = page.locator(
|
||||
'[role="dialog"] button, .fixed button[type="button"]',
|
||||
).filter({ hasText: /./ });
|
||||
const widgetButtons = page
|
||||
.locator('[role="dialog"] button, .fixed button[type="button"]')
|
||||
.filter({ hasText: /./ });
|
||||
|
||||
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
||||
const modalContent = page.locator(".fixed.inset-0 .grid");
|
||||
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
|
||||
const initialCount = await page.locator(".react-grid-item").count();
|
||||
|
||||
// Open modal and add "Resource Table" widget
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
|
||||
await page
|
||||
.locator(".fixed.inset-0 button")
|
||||
.filter({ hasText: /resource table/i })
|
||||
.click();
|
||||
|
||||
// Modal should close after adding
|
||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
|
||||
await navigateToDashboard(page);
|
||||
|
||||
// Add a recognizable widget
|
||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await page
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
|
||||
await page
|
||||
.locator(".fixed.inset-0 button")
|
||||
.filter({ hasText: /project overview/i })
|
||||
.click();
|
||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
const countAfterAdd = await page.locator(".react-grid-item").count();
|
||||
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Read the admin's localStorage key to verify it is user-scoped
|
||||
const adminUserId = await adminPage.evaluate(async () => {
|
||||
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
|
||||
credentials: "include",
|
||||
});
|
||||
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||
const res = await fetch(
|
||||
"/api/trpc/user.me?batch=1&input=" +
|
||||
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||
return body[0]?.result?.data?.json?.id ?? null;
|
||||
});
|
||||
|
||||
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
|
||||
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
|
||||
if (adminUserId) {
|
||||
const storageKey = await adminPage.evaluate((userId) => {
|
||||
// Check both old (unscoped) and new (user-scoped) key formats
|
||||
const oldKey = "capakraken_dashboard_v1";
|
||||
const newKey = `capakraken_dashboard_v1_${userId}`;
|
||||
const oldKey = "nexus_dashboard_v1";
|
||||
const newKey = `nexus_dashboard_v1_${userId}`;
|
||||
const oldValue = localStorage.getItem(oldKey);
|
||||
const newValue = localStorage.getItem(newKey);
|
||||
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
||||
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
|
||||
|
||||
// Inject the admin's storage key to simulate same browser
|
||||
await newUserPage.evaluate(
|
||||
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
|
||||
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
|
||||
({ key, value }) => {
|
||||
localStorage.setItem(key, value ?? "");
|
||||
},
|
||||
{
|
||||
key: `nexus_dashboard_v1_${adminUserId}`,
|
||||
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
|
||||
},
|
||||
);
|
||||
|
||||
// Log in as test user
|
||||
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
|
||||
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
||||
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
||||
// The key check: the test user's Add Widget button should still work
|
||||
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
|
||||
await newUserPage
|
||||
.getByRole("button", { name: /add widget/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Modal must show widgets to choose from
|
||||
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
||||
|
||||
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
|
||||
password: "Dev123456!",
|
||||
};
|
||||
|
||||
const DB_CONTAINER = "capakraken-postgres-1";
|
||||
const DB_USER = "capakraken";
|
||||
const DB_NAME = "capakraken";
|
||||
const DB_CONTAINER = "nexus-postgres-1";
|
||||
const DB_USER = "nexus";
|
||||
const DB_NAME = "nexus";
|
||||
|
||||
function psqlExec(sql: string): string {
|
||||
return execSync(
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
|
||||
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
||||
await page.evaluate(async () => {
|
||||
const csrfRes = await fetch("/api/auth/csrf");
|
||||
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
|
||||
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
|
||||
await fetch("/api/auth/signout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
@@ -62,11 +62,9 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
|
||||
const enc = (encoding ?? "").toLowerCase().trim();
|
||||
if (enc === "quoted-printable") {
|
||||
return body
|
||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||
.replace(/=\n/g, "") // soft line break (LF)
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
);
|
||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||
.replace(/=\n/g, "") // soft line break (LF)
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
||||
}
|
||||
if (enc === "base64") {
|
||||
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
||||
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
|
||||
*/
|
||||
export async function getLatestEmailTo(
|
||||
address: string,
|
||||
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||
{
|
||||
timeoutMs = 10_000,
|
||||
pollIntervalMs = 500,
|
||||
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||
): Promise<{ subject: string; body: string; html: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
|
||||
pathPrefix: string,
|
||||
): string {
|
||||
const text = email.html || email.body;
|
||||
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
|
||||
const match = text.match(
|
||||
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
|
||||
);
|
||||
if (!match?.[0]) {
|
||||
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
||||
}
|
||||
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
|
||||
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
||||
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
||||
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
||||
execSync(
|
||||
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
|
||||
{ input: sql, encoding: "utf8" },
|
||||
);
|
||||
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
|
||||
input: sql,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
|
||||
});
|
||||
|
||||
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
||||
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
|
||||
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
|
||||
|
||||
// Step 1: Navigate to admin users page
|
||||
await page.goto("/admin/users");
|
||||
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
|
||||
// Step 2: Open invite modal
|
||||
await page.click('button:has-text("Invite User")');
|
||||
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
||||
await page.waitForSelector('text=Invite User', { state: "visible" });
|
||||
await page.waitForSelector("text=Invite User", { state: "visible" });
|
||||
|
||||
// Step 3: Fill in invite form
|
||||
await page.fill('input[type="email"]', testEmail);
|
||||
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
|
||||
await page.click('button:has-text("Send Invite")');
|
||||
|
||||
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Step 6: Read invite email from Mailhog
|
||||
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
||||
|
||||
@@ -21,9 +21,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
||||
|
||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
||||
type TrpcResult = {
|
||||
result?: { data?: unknown };
|
||||
error?: { data?: { code?: string }; message?: string };
|
||||
};
|
||||
|
||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcMutation(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||
@@ -39,7 +46,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
||||
);
|
||||
}
|
||||
|
||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
||||
async function trpcQuery(
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: unknown = null,
|
||||
): Promise<TrpcResult> {
|
||||
return page.evaluate(
|
||||
async ({ procedure, input }) => {
|
||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||
@@ -60,7 +71,7 @@ async function enableMfaForSession(page: Page): Promise<TOTP> {
|
||||
if (!data?.secret) throw new Error(`generateTotpSecret failed: ${JSON.stringify(genRes)}`);
|
||||
|
||||
const totp = new TOTP({
|
||||
issuer: "CapaKraken",
|
||||
issuer: "Nexus",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
@@ -92,7 +103,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Clean up: disable MFA if a test enabled it
|
||||
if (totp) {
|
||||
await disableMfaForSession(page).catch(() => {/* already disabled or admin override */});
|
||||
await disableMfaForSession(page).catch(() => {
|
||||
/* already disabled or admin override */
|
||||
});
|
||||
totp = null;
|
||||
}
|
||||
});
|
||||
@@ -106,7 +119,7 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
||||
|
||||
expect(data?.secret).toBeTruthy();
|
||||
expect(data?.uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(data?.uri).toContain("CapaKraken");
|
||||
expect(data?.uri).toContain("Nexus");
|
||||
});
|
||||
|
||||
test("verifyAndEnableTotp accepts a valid code and enables MFA", async ({ page }) => {
|
||||
@@ -137,9 +150,9 @@ test.describe("MFA — setup flow (account/security page)", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Click the enable/setup button if MFA is not yet enabled
|
||||
const setupBtn = page.getByRole("button", { name: /set up/i }).or(
|
||||
page.getByRole("button", { name: /enable.*mfa/i }),
|
||||
);
|
||||
const setupBtn = page
|
||||
.getByRole("button", { name: /set up/i })
|
||||
.or(page.getByRole("button", { name: /enable.*mfa/i }));
|
||||
|
||||
if (await setupBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await setupBtn.click();
|
||||
@@ -233,9 +246,10 @@ test.describe("MFA — login flow", () => {
|
||||
|
||||
// Should show error and remain on TOTP step
|
||||
await expect(
|
||||
page.getByText(/invalid.*code|incorrect.*token|try again/i).or(
|
||||
page.locator("[data-error]"),
|
||||
).first(),
|
||||
page
|
||||
.getByText(/invalid.*code|incorrect.*token|try again/i)
|
||||
.or(page.locator("[data-error]"))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should NOT have navigated away
|
||||
@@ -248,7 +262,9 @@ test.describe("MFA — login flow", () => {
|
||||
test.describe("MFA — users without MFA enabled", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({ page }) => {
|
||||
test("login for MFA-less user goes straight to dashboard without TOTP prompt", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Auth: e2e/dev-system/.auth/admin.json (created by global-setup.ts)
|
||||
*
|
||||
* Run:
|
||||
* pnpm --filter @capakraken/web exec playwright test \
|
||||
* pnpm --filter @nexus/web exec playwright test \
|
||||
* --config playwright.dev.config.ts \
|
||||
* e2e/dev-system/nav-smoke.spec.ts
|
||||
*/
|
||||
|
||||
@@ -27,10 +27,10 @@ test.describe("RBAC — admin routes (admin session)", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||
// Seed users have planarchy.dev or capakraken.dev email domains
|
||||
await expect(
|
||||
page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
// Seed users have planarchy.dev or nexus.dev email domains
|
||||
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin can access /admin/system-roles without errors", async ({ page }) => {
|
||||
@@ -99,9 +99,10 @@ test.describe("RBAC — allocations permitted for admin", () => {
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(
|
||||
page.locator("text=/do not have permission to view allocations/i"),
|
||||
).toHaveCount(0, { timeout: 8000 });
|
||||
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
||||
0,
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,9 +113,10 @@ test.describe("RBAC — allocations permitted for manager", () => {
|
||||
await page.goto("/allocations");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(
|
||||
page.locator("text=/do not have permission to view allocations/i"),
|
||||
).toHaveCount(0, { timeout: 8000 });
|
||||
await expect(page.locator("text=/do not have permission to view allocations/i")).toHaveCount(
|
||||
0,
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,22 +10,26 @@ async function signIn(page: Page, email: string, password: string) {
|
||||
|
||||
test.describe("Estimates", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await signIn(page, "admin@capakraken.dev", "admin123");
|
||||
await signIn(page, "admin@nexus.dev", "admin123");
|
||||
await page.goto("/estimates");
|
||||
});
|
||||
|
||||
test("estimate list loads", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByRole("heading", { name: /estimate workspace/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByPlaceholder("Search by estimate or opportunity")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /estimate workspace/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByPlaceholder("Search by estimate or opportunity"),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.locator("text=No estimates yet").or(
|
||||
page.locator("text=Select an estimate to inspect the current version, demand lines, and summary metrics."),
|
||||
),
|
||||
page
|
||||
.locator("text=No estimates yet")
|
||||
.or(
|
||||
page.locator(
|
||||
"text=Select an estimate to inspect the current version, demand lines, and summary metrics.",
|
||||
),
|
||||
),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
@@ -44,8 +48,13 @@ test.describe("Estimates", () => {
|
||||
await page.locator("button", { hasText: /New Estimate/i }).click();
|
||||
|
||||
// Step 1: Setup — fill a name
|
||||
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({ timeout: 5000 });
|
||||
const nameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
||||
await expect(page.getByRole("button", { name: /Step 1 Setup/i })).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
const nameInput = page
|
||||
.locator('input[placeholder*="name"]')
|
||||
.or(page.locator('input[name="name"]'))
|
||||
.first();
|
||||
if ((await nameInput.count()) > 0) {
|
||||
await nameInput.fill(`E2E Estimate ${Date.now()}`);
|
||||
}
|
||||
@@ -90,9 +99,7 @@ test.describe("Estimates", () => {
|
||||
|
||||
test("shows the empty-state fallback when no estimates exist", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("text=No estimates yet"),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=No estimates yet")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("shows an estimate-not-found fallback for unknown workspaces", async ({ page }) => {
|
||||
@@ -103,12 +110,14 @@ test.describe("Estimates", () => {
|
||||
|
||||
test("shows the restricted workspace fallback for viewers", async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
await signIn(page, "viewer@capakraken.dev", "viewer123");
|
||||
await signIn(page, "viewer@nexus.dev", "viewer123");
|
||||
await page.goto("/estimates/missing-estimate");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(
|
||||
page.locator("text=Your role can access the estimate list, but not the detailed financial workspace."),
|
||||
page.locator(
|
||||
"text=Your role can access the estimate list, but not the detailed financial workspace.",
|
||||
),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.close();
|
||||
|
||||
@@ -2,14 +2,16 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Holiday Calendar Editor", () => {
|
||||
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => {
|
||||
test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suffix = Date.now().toString();
|
||||
const calendarName = `E2E City Calendar ${suffix}`;
|
||||
const holidayName = `E2E Local Holiday ${suffix}`;
|
||||
@@ -21,11 +23,18 @@ test.describe("Holiday Calendar Editor", () => {
|
||||
|
||||
await page.getByTestId("holiday-calendar-name-input").fill(calendarName);
|
||||
await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY");
|
||||
await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" });
|
||||
await page
|
||||
.getByTestId("holiday-calendar-country-select")
|
||||
.selectOption({ label: "Germany (DE)" });
|
||||
await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" });
|
||||
await page.getByTestId("holiday-calendar-create-button").click();
|
||||
|
||||
await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(/holiday-calendar-row-/)
|
||||
.filter({ hasText: calendarName })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: calendarName })).toBeVisible();
|
||||
await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible();
|
||||
|
||||
@@ -44,10 +53,15 @@ test.describe("Holiday Calendar Editor", () => {
|
||||
await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`);
|
||||
await page.getByTestId("holiday-entry-create-button").click();
|
||||
|
||||
await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("A holiday entry for this calendar and date already exists"),
|
||||
).toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId(/holiday-entry-delete-/).first().click();
|
||||
await page
|
||||
.getByTestId(/holiday-entry-delete-/)
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByText(holidayName).first()).not.toBeVisible();
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -28,7 +28,7 @@ test.describe("Navigation", () => {
|
||||
|
||||
test("all nav routes resolve — no 404 (smoke)", async ({ page }) => {
|
||||
// Complements the click-based test above with a direct-navigation check
|
||||
// covering every sidebar destination. Uses admin@capakraken.dev (ADMIN role).
|
||||
// covering every sidebar destination. Uses admin@nexus.dev (ADMIN role).
|
||||
const routes = [
|
||||
// Already covered by click test but included for completeness
|
||||
"/dashboard",
|
||||
@@ -79,7 +79,10 @@ test.describe("Navigation", () => {
|
||||
}
|
||||
|
||||
// Expand again — the button should still be visible as an icon
|
||||
const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last();
|
||||
const expandBtn = page
|
||||
.locator("nav button")
|
||||
.filter({ has: page.locator("svg") })
|
||||
.last();
|
||||
await expandBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const boxExpanded = await nav.boundingBox();
|
||||
@@ -113,7 +116,10 @@ test.describe("Navigation", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// The hamburger button should be visible on mobile
|
||||
const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first();
|
||||
const hamburgerBtn = page
|
||||
.locator("button")
|
||||
.filter({ has: page.locator("svg") })
|
||||
.first();
|
||||
await expect(hamburgerBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await hamburgerBtn.click();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -74,9 +74,9 @@ test.describe("Project Detail Page", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// BudgetStatusCard renders budget-related content
|
||||
await expect(
|
||||
page.locator("text=Budget").or(page.locator("text=budget")).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=Budget").or(page.locator("text=budget")).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("unknown project id shows not-found state", async ({ page }) => {
|
||||
@@ -85,7 +85,11 @@ test.describe("Project Detail Page", () => {
|
||||
|
||||
// Server-side notFound() triggers the Next.js 404 page
|
||||
await expect(
|
||||
page.locator("text=404").or(page.locator("text=Not Found")).or(page.locator("text=not found")).first(),
|
||||
page
|
||||
.locator("text=404")
|
||||
.or(page.locator("text=Not Found"))
|
||||
.or(page.locator("text=not found"))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Projects", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "manager@nexus.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/resources/);
|
||||
@@ -26,9 +26,16 @@ test.describe("Projects", () => {
|
||||
// Step 1: Blueprint selection
|
||||
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
||||
// Select the first available blueprint
|
||||
const blueprintCard = page.locator("[data-blueprint-id]").first()
|
||||
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first());
|
||||
if (await blueprintCard.count() > 0) {
|
||||
const blueprintCard = page
|
||||
.locator("[data-blueprint-id]")
|
||||
.first()
|
||||
.or(
|
||||
page
|
||||
.locator("button")
|
||||
.filter({ hasText: /Blueprint|Production/ })
|
||||
.first(),
|
||||
);
|
||||
if ((await blueprintCard.count()) > 0) {
|
||||
await blueprintCard.click();
|
||||
} else {
|
||||
// Click next without blueprint if none shown
|
||||
@@ -37,16 +44,21 @@ test.describe("Projects", () => {
|
||||
}
|
||||
|
||||
// Step 2: Timeline — set project dates
|
||||
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 });
|
||||
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
||||
if (await projectNameInput.count() > 0) {
|
||||
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
const projectNameInput = page
|
||||
.locator('input[placeholder*="name"]')
|
||||
.or(page.locator('input[name="name"]'))
|
||||
.first();
|
||||
if ((await projectNameInput.count()) > 0) {
|
||||
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
||||
}
|
||||
await page.locator("button", { hasText: "Next" }).click();
|
||||
|
||||
// Step 3: Staffing demand
|
||||
await expect(
|
||||
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles")))
|
||||
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles"))),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await page.locator("button", { hasText: "Next" }).click();
|
||||
|
||||
@@ -56,11 +68,13 @@ test.describe("Projects", () => {
|
||||
|
||||
// Step 5: Review
|
||||
await page.waitForTimeout(500);
|
||||
const reviewOrFinish = page.locator("text=Review").or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
||||
const reviewOrFinish = page
|
||||
.locator("text=Review")
|
||||
.or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
||||
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
||||
// Don't actually submit — just close
|
||||
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
||||
if (await cancelBtn.count() > 0) {
|
||||
if ((await cancelBtn.count()) > 0) {
|
||||
await cancelBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -16,16 +16,17 @@ test.describe("Chargeability Report", () => {
|
||||
|
||||
test("chargeability forecast page loads with heading", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1", { hasText: "Chargeability Forecast" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: "Chargeability Forecast" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("filter controls are present", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Should have at least one filter (e.g., chapter, period, resource search)
|
||||
await expect(
|
||||
page.locator('input[type="text"]')
|
||||
page
|
||||
.locator('input[type="text"]')
|
||||
.or(page.locator('input[type="search"]'))
|
||||
.or(page.locator("select"))
|
||||
.first(),
|
||||
@@ -64,9 +65,9 @@ test.describe("Report Builder", () => {
|
||||
|
||||
test("report builder page loads with heading", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Report Builder" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole("heading", { name: "Report Builder" })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("entity selector is present with expected options", async ({ page }) => {
|
||||
@@ -78,9 +79,9 @@ test.describe("Report Builder", () => {
|
||||
|
||||
test("run report button is visible", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("button", { hasText: /Run|Export|Generate/i }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("button", { hasText: /Run|Export|Generate/i }).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("running a default report produces output or empty state", async ({ page }) => {
|
||||
@@ -90,7 +91,11 @@ test.describe("Report Builder", () => {
|
||||
await runBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(
|
||||
page.locator("table").or(page.locator("text=No rows")).or(page.locator("text=0 rows")).first(),
|
||||
page
|
||||
.locator("table")
|
||||
.or(page.locator("text=No rows"))
|
||||
.or(page.locator("text=0 rows"))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Resources", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "manager@nexus.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -21,10 +21,11 @@ test.describe("Resources", () => {
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
const firstRowText = (await rows.first().textContent()) ?? "";
|
||||
const searchTerm = firstRowText
|
||||
.split(/\s+/)
|
||||
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
||||
.find((token) => token.length >= 3) ?? "EMP";
|
||||
const searchTerm =
|
||||
firstRowText
|
||||
.split(/\s+/)
|
||||
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
||||
.find((token) => token.length >= 3) ?? "EMP";
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(searchTerm);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -16,15 +16,16 @@ test.describe("Scenario Planning", () => {
|
||||
|
||||
test("scenarios page loads with heading", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("h1", { hasText: /Scenario Planning/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: /Scenario Planning/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows scenarios list or empty state", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("table")
|
||||
page
|
||||
.locator("table")
|
||||
.or(page.locator("text=No scenarios"))
|
||||
.or(page.locator("text=Create a project first"))
|
||||
.or(page.locator("[data-testid]"))
|
||||
|
||||
@@ -29,7 +29,7 @@ test("signin page renders credential inputs and submit button", async ({ page })
|
||||
|
||||
test("admin login succeeds and redirects away from signin", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||
@@ -37,7 +37,7 @@ test("admin login succeeds and redirects away from signin", async ({ page }) =>
|
||||
|
||||
test("authenticated user sees app shell nav", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).not.toHaveURL(/\/auth\/signin/, { timeout: 15_000 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
test.describe("Staffing", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -12,7 +12,9 @@ test.describe("Staffing", () => {
|
||||
|
||||
test("staffing page loads with search form", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1", { hasText: /Staffing Suggestions/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Search form should have skill input, date fields, and a search button
|
||||
await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
@@ -20,9 +22,9 @@ test.describe("Staffing", () => {
|
||||
test("search form has default skill tags", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// The StaffingPanel pre-populates with TypeScript and React skill tags
|
||||
await expect(
|
||||
page.locator("text=TypeScript").or(page.locator("text=React")),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("text=TypeScript").or(page.locator("text=React"))).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting search returns suggestions or empty state", async ({ page }) => {
|
||||
@@ -34,7 +36,9 @@ test.describe("Staffing", () => {
|
||||
await page.waitForTimeout(1000);
|
||||
// After search, should show either suggestion cards or a "no suggestions" message
|
||||
await expect(
|
||||
page.locator("text=/Score|Availability|No suggestions|No matching/i").first()
|
||||
page
|
||||
.locator("text=/Score|Availability|No suggestions|No matching/i")
|
||||
.first()
|
||||
.or(page.locator("[data-suggestion]").first())
|
||||
.or(page.locator("table").first()),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
|
||||
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
|
||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
|
||||
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
||||
const composeProjectName = `nexus-e2e-${process.pid}`;
|
||||
const managedEnvKeys = [
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
@@ -29,7 +29,7 @@ const managedEnvKeys = [
|
||||
"NODE_ENV",
|
||||
"PORT",
|
||||
];
|
||||
const e2eComposePrefix = "capakraken-e2e-";
|
||||
const e2eComposePrefix = "nexus-e2e-";
|
||||
|
||||
function dockerComposeArgs(...args) {
|
||||
return ["compose", "-p", composeProjectName, ...args];
|
||||
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
|
||||
try {
|
||||
await runQuiet(
|
||||
"docker",
|
||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
|
||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
|
||||
workspaceRoot,
|
||||
);
|
||||
return;
|
||||
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
||||
if (selectedTestDbPort !== undefined) {
|
||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||
}
|
||||
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
||||
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
||||
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||
@@ -393,9 +393,9 @@ try {
|
||||
await cleanupStaleE2eArtifacts();
|
||||
await ensureE2eDatabaseContainer();
|
||||
}
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@nexus/db", "db:push"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@nexus/db", "db:seed"], workspaceRoot);
|
||||
await run("pnpm", ["--filter", "@nexus/db", "db:seed:holidays"], workspaceRoot);
|
||||
rmSync(webDistDirPath, { recursive: true, force: true });
|
||||
|
||||
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
|
||||
|
||||
+289
-162
@@ -133,7 +133,7 @@ function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario
|
||||
data: {
|
||||
eid: ${JSON.stringify(`e2e.timeline.${suffix}`)},
|
||||
displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)},
|
||||
email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)},
|
||||
email: ${JSON.stringify(`e2e.timeline.${suffix}@nexus.dev`)},
|
||||
chapter: "E2E",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
@@ -208,7 +208,7 @@ function createTimelineDemandScenario(suffix: string): TimelineDemandScenario {
|
||||
data: {
|
||||
eid: ${JSON.stringify(`e2e.timeline.demand.${suffix}`)},
|
||||
displayName: ${JSON.stringify(`E2E Timeline Demand Resource ${suffix}`)},
|
||||
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@capakraken.dev`)},
|
||||
email: ${JSON.stringify(`e2e.timeline.demand.${suffix}@nexus.dev`)},
|
||||
chapter: "E2E",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
@@ -341,7 +341,9 @@ function listScenarioAssignments(projectId: string) {
|
||||
}
|
||||
|
||||
function listScenarioDemands(projectId: string) {
|
||||
return runDbJson<Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>>(`
|
||||
return runDbJson<
|
||||
Array<{ id: string; startDate: string; endDate: string; headcount: number; status: string }>
|
||||
>(`
|
||||
const demands = await prisma.demandRequirement.findMany({
|
||||
where: { projectId: ${JSON.stringify(projectId)} },
|
||||
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
|
||||
@@ -448,10 +450,7 @@ async function openAllocationContextMenuAtOffset(
|
||||
);
|
||||
}
|
||||
|
||||
async function openContextMenuAtCenter(
|
||||
page: Page,
|
||||
locator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
async function openContextMenuAtCenter(page: Page, locator: ReturnType<Page["locator"]>) {
|
||||
const target = await resolveAllocationContextMenuTarget(locator);
|
||||
const box = await readBoundingBox(target);
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||
@@ -511,9 +510,7 @@ async function listRenderedAllocationSegments(
|
||||
row: ReturnType<Page["locator"]>,
|
||||
allocationId?: string,
|
||||
) {
|
||||
const selector = allocationId
|
||||
? `[data-allocation-id="${allocationId}"]`
|
||||
: "[data-allocation-id]";
|
||||
const selector = allocationId ? `[data-allocation-id="${allocationId}"]` : "[data-allocation-id]";
|
||||
return row.locator(selector).evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
@@ -536,17 +533,13 @@ function escapeRegex(value: string) {
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
async function findVisibleTimelineEntryId(
|
||||
page: Page,
|
||||
selector: string,
|
||||
minimumWidth = 24,
|
||||
) {
|
||||
async function findVisibleTimelineEntryId(page: Page, selector: string, minimumWidth = 24) {
|
||||
return page.locator(selector).evaluateAll((elements, minimum) => {
|
||||
for (const element of elements) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
@@ -600,9 +593,9 @@ async function findVisibleAllocationSegmentForResize(
|
||||
);
|
||||
const stickyHeaderBottom = scrollContainer
|
||||
? Array.from(scrollContainer.querySelectorAll<HTMLElement>(".sticky.top-0")).reduce(
|
||||
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
||||
0,
|
||||
)
|
||||
(maxBottom, element) => Math.max(maxBottom, element.getBoundingClientRect().bottom),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const safeTop = stickyHeaderBottom > 0 ? stickyHeaderBottom + 8 : 48;
|
||||
const candidates: Array<{
|
||||
@@ -611,8 +604,11 @@ async function findVisibleAllocationSegmentForResize(
|
||||
segmentEnd: string | null;
|
||||
score: number;
|
||||
}> = [];
|
||||
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
|
||||
null;
|
||||
let fallback: {
|
||||
allocationId: string;
|
||||
segmentStart: string | null;
|
||||
segmentEnd: string | null;
|
||||
} | null = null;
|
||||
|
||||
for (const element of elements) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
@@ -829,13 +825,20 @@ async function switchToProjectView(page: Page, readySelector?: string) {
|
||||
await expect(page.locator(readySelector).first()).toBeVisible();
|
||||
} else {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const projectRows = await page.getByTestId("timeline-project-resource-row-canvas").count();
|
||||
const projectBars = await page.locator("[data-timeline-entry-type='project-bar']").count();
|
||||
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
||||
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
||||
return projectRows + projectBars + demandBars + emptyStates;
|
||||
}, { timeout: 10_000 })
|
||||
.poll(
|
||||
async () => {
|
||||
const projectRows = await page
|
||||
.getByTestId("timeline-project-resource-row-canvas")
|
||||
.count();
|
||||
const projectBars = await page
|
||||
.locator("[data-timeline-entry-type='project-bar']")
|
||||
.count();
|
||||
const demandBars = await page.locator("[data-timeline-entry-type='demand']").count();
|
||||
const emptyStates = await page.getByText(/No projects in this time range/).count();
|
||||
return projectRows + projectBars + demandBars + emptyStates;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.not.toBe(0);
|
||||
}
|
||||
await expect(page.getByTestId("timeline-resource-row-canvas")).toHaveCount(0);
|
||||
@@ -853,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
|
||||
|
||||
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("capakraken_prefs");
|
||||
const raw = window.localStorage.getItem("nexus_prefs");
|
||||
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
window.localStorage.setItem(
|
||||
"capakraken_prefs",
|
||||
"nexus_prefs",
|
||||
JSON.stringify({
|
||||
...parsed,
|
||||
showDemandProjects: true,
|
||||
@@ -871,9 +874,9 @@ test.describe("Timeline", () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||||
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
|
||||
localStorage.setItem(
|
||||
"capakraken_prefs",
|
||||
"nexus_prefs",
|
||||
JSON.stringify({
|
||||
hideCompletedProjects: true,
|
||||
timelineDisplayMode: "strip",
|
||||
@@ -906,22 +909,21 @@ test.describe("Timeline", () => {
|
||||
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
||||
});
|
||||
|
||||
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({ page }) => {
|
||||
test("view toggle stays disabled until the initial timeline load becomes interactive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const scenario = createTimelineSegmentScenario(suffix);
|
||||
|
||||
try {
|
||||
await page.goto(
|
||||
`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&eids=${scenario.resourceEid}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const projectButton = page.getByRole("button", { name: "Project view" });
|
||||
const resourceButton = page.getByRole("button", { name: "Resource view" });
|
||||
const resourceRowSelector =
|
||||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||
const projectRowSelector =
|
||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
|
||||
await expect(projectButton).toBeDisabled();
|
||||
await expect(resourceButton).toBeDisabled();
|
||||
@@ -951,9 +953,9 @@ test.describe("Timeline", () => {
|
||||
|
||||
test("keeps timeline data populated after navigating from allocations", async ({ page }) => {
|
||||
await page.goto("/allocations");
|
||||
await expect(
|
||||
page.locator("h1").filter({ hasText: /Allocations|Planning/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("h1").filter({ hasText: /Allocations|Planning/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.locator('nav a >> text="Timeline"').first().click();
|
||||
await expect(page).toHaveURL(/\/timeline/);
|
||||
@@ -1046,7 +1048,10 @@ test.describe("Timeline", () => {
|
||||
if (!projectAllocationBox) {
|
||||
throw new Error("Expected a project allocation block to be available");
|
||||
}
|
||||
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||||
await page.mouse.move(
|
||||
projectAllocationBox.x + projectAllocationBox.width / 2,
|
||||
projectHoverBox.y + 20,
|
||||
);
|
||||
await expect(heatmapTooltip).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -1071,7 +1076,9 @@ test.describe("Timeline", () => {
|
||||
.first();
|
||||
await allocation.click({ button: "right" });
|
||||
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, {
|
||||
timeout: 2_000,
|
||||
});
|
||||
const popover = page.getByTestId("timeline-allocation-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||
@@ -1103,12 +1110,16 @@ test.describe("Timeline", () => {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||||
const row = page
|
||||
.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]')
|
||||
.first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const holidayBlock = row.locator(
|
||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||
).first();
|
||||
const holidayBlock = row
|
||||
.locator(
|
||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||
)
|
||||
.first();
|
||||
await expect(holidayBlock).toBeVisible();
|
||||
|
||||
const rowBox = await row.boundingBox();
|
||||
@@ -1129,7 +1140,9 @@ test.describe("Timeline", () => {
|
||||
|
||||
const holidayTooltip = page
|
||||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||||
.or(
|
||||
page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }),
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(holidayTooltip).toBeVisible();
|
||||
@@ -1278,9 +1291,7 @@ test.describe("Timeline", () => {
|
||||
expect(result.maxGap).toBeLessThan(24);
|
||||
});
|
||||
|
||||
test("allocation resize shows a live preview before mouseup", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("allocation resize shows a live preview before mouseup", async ({ page }) => {
|
||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
@@ -1358,9 +1369,7 @@ test.describe("Timeline", () => {
|
||||
expect(secondResize.rightEdgeGain).toBeGreaterThan(48);
|
||||
});
|
||||
|
||||
test("allocation start resize shows a live preview before mouseup", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("allocation start resize shows a live preview before mouseup", async ({ page }) => {
|
||||
await page.goto("/timeline?startDate=2026-04-01&days=31", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
@@ -1394,18 +1403,17 @@ test.describe("Timeline", () => {
|
||||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const resourceRowSelector =
|
||||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||
const projectRowSelector =
|
||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
const projectAllocationSelector =
|
||||
`${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
||||
const resourceRowSelector = `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`;
|
||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
const projectAllocationSelector = `${projectRowSelector} [data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`;
|
||||
|
||||
await expect(page.locator(resourceRowSelector)).toBeVisible();
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
||||
).first(),
|
||||
page
|
||||
.locator(
|
||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${scenario.assignmentId}"]`,
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await switchToProjectView(page, projectRowSelector);
|
||||
@@ -1427,19 +1435,22 @@ test.describe("Timeline", () => {
|
||||
expect(resizeEnd.rightEdgeGain).toBeGreaterThan(48);
|
||||
let rightResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||
await expect
|
||||
.poll(() => {
|
||||
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||
if (rightResizeAssignments.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
.poll(
|
||||
() => {
|
||||
rightResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||
if (rightResizeAssignments.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [assignment] = rightResizeAssignments;
|
||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||
return null;
|
||||
}
|
||||
const [assignment] = rightResizeAssignments;
|
||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return assignment.endDate;
|
||||
}, { timeout: 15_000 })
|
||||
return assignment.endDate;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBe("2026-04-17");
|
||||
expect(rightResizeAssignments).toHaveLength(1);
|
||||
expect(rightResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||
@@ -1451,19 +1462,22 @@ test.describe("Timeline", () => {
|
||||
expect(resizeStart.leftEdgeGain).toBeGreaterThan(36);
|
||||
let leftResizeAssignments: Array<{ id: string; startDate: string; endDate: string }> = [];
|
||||
await expect
|
||||
.poll(() => {
|
||||
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||
if (leftResizeAssignments.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
.poll(
|
||||
() => {
|
||||
leftResizeAssignments = listScenarioAssignments(scenario.projectId);
|
||||
if (leftResizeAssignments.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [assignment] = leftResizeAssignments;
|
||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||
return null;
|
||||
}
|
||||
const [assignment] = leftResizeAssignments;
|
||||
if (!assignment || assignment.id !== scenario.assignmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return assignment.startDate;
|
||||
}, { timeout: 15_000 })
|
||||
return assignment.startDate;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBe("2026-04-06");
|
||||
expect(leftResizeAssignments).toHaveLength(1);
|
||||
expect(leftResizeAssignments[0]?.id).toBe(scenario.assignmentId);
|
||||
@@ -1479,15 +1493,12 @@ test.describe("Timeline", () => {
|
||||
const scenario = createTimelineDemandScenario(suffix);
|
||||
|
||||
try {
|
||||
await page.goto(
|
||||
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await ensureOpenDemandVisibilityEnabled(page);
|
||||
const demandRowSelector =
|
||||
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||
const demandSelector =
|
||||
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||
|
||||
await switchToProjectView(page, demandRowSelector);
|
||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||
@@ -1505,19 +1516,22 @@ test.describe("Timeline", () => {
|
||||
status: string;
|
||||
}> = [];
|
||||
await expect
|
||||
.poll(() => {
|
||||
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
||||
if (rightResizeDemands.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
.poll(
|
||||
() => {
|
||||
rightResizeDemands = listScenarioDemands(scenario.projectId);
|
||||
if (rightResizeDemands.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [demand] = rightResizeDemands;
|
||||
if (!demand || demand.id !== scenario.demandId) {
|
||||
return null;
|
||||
}
|
||||
const [demand] = rightResizeDemands;
|
||||
if (!demand || demand.id !== scenario.demandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return demand.endDate;
|
||||
}, { timeout: 15_000 })
|
||||
return demand.endDate;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBe("2026-04-16");
|
||||
expect(rightResizeDemands).toHaveLength(1);
|
||||
expect(rightResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||
@@ -1538,19 +1552,22 @@ test.describe("Timeline", () => {
|
||||
status: string;
|
||||
}> = [];
|
||||
await expect
|
||||
.poll(() => {
|
||||
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
||||
if (leftResizeDemands.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
.poll(
|
||||
() => {
|
||||
leftResizeDemands = listScenarioDemands(scenario.projectId);
|
||||
if (leftResizeDemands.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [demand] = leftResizeDemands;
|
||||
if (!demand || demand.id !== scenario.demandId) {
|
||||
return null;
|
||||
}
|
||||
const [demand] = leftResizeDemands;
|
||||
if (!demand || demand.id !== scenario.demandId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return demand.startDate;
|
||||
}, { timeout: 15_000 })
|
||||
return demand.startDate;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.not.toBe("2026-04-07");
|
||||
expect(leftResizeDemands).toHaveLength(1);
|
||||
expect(leftResizeDemands[0]?.id).toBe(scenario.demandId);
|
||||
@@ -1630,7 +1647,11 @@ test.describe("Timeline", () => {
|
||||
);
|
||||
await expect(resizedSegment).toBeVisible();
|
||||
|
||||
await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth);
|
||||
await dragLocatorBy(
|
||||
page,
|
||||
resizedSegment.locator('[data-allocation-interaction="body"]'),
|
||||
-dayWidth,
|
||||
);
|
||||
await releaseMouse(page);
|
||||
|
||||
await waitForScenarioAssignments(scenario.projectId, [
|
||||
@@ -1674,9 +1695,21 @@ test.describe("Timeline", () => {
|
||||
{ startDate: "2026-04-11", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||
const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||
const leftSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first();
|
||||
const rightSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first();
|
||||
const nextWeekSegment = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(leftSplit).toBeVisible();
|
||||
await expect(rightSplit).toBeVisible();
|
||||
await expect(nextWeekSegment).toBeVisible();
|
||||
@@ -1704,22 +1737,42 @@ test.describe("Timeline", () => {
|
||||
]);
|
||||
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||
@@ -1769,9 +1822,21 @@ test.describe("Timeline", () => {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||
const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first();
|
||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||
const leftSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first();
|
||||
const fridayBridge = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first();
|
||||
const mondaySegment = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(leftSplit).toBeVisible();
|
||||
await expect(fridayBridge).toBeVisible();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
@@ -1797,13 +1862,25 @@ test.describe("Timeline", () => {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||
@@ -1850,9 +1927,21 @@ test.describe("Timeline", () => {
|
||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||
const leftSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first();
|
||||
const rightSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first();
|
||||
const mondaySegment = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(leftSplit).toBeVisible();
|
||||
await expect(rightSplit).toBeVisible();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
@@ -1870,8 +1959,16 @@ test.describe("Timeline", () => {
|
||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||
await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth);
|
||||
const resizedRightSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first();
|
||||
await dragLocatorBy(
|
||||
page,
|
||||
resizedRightSplit.locator('[data-allocation-handle="end"]'),
|
||||
-dayWidth,
|
||||
);
|
||||
await releaseMouse(page);
|
||||
|
||||
await waitForScenarioAssignments(scenario.projectId, [
|
||||
@@ -1883,9 +1980,11 @@ test.describe("Timeline", () => {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const mondaySegmentAfterReload = row.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
).first();
|
||||
const mondaySegmentAfterReload = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(mondaySegmentAfterReload).toBeVisible();
|
||||
|
||||
const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||||
@@ -1951,9 +2050,21 @@ test.describe("Timeline", () => {
|
||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first();
|
||||
const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first();
|
||||
const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first();
|
||||
const leftSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first();
|
||||
const rightSplit = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first();
|
||||
const mondaySegment = row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(leftSplit).toBeVisible();
|
||||
await expect(rightSplit).toBeVisible();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
@@ -1968,7 +2079,11 @@ test.describe("Timeline", () => {
|
||||
]);
|
||||
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first(),
|
||||
).toHaveCount(0);
|
||||
await expect(rightSplit).toBeVisible();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
@@ -1976,13 +2091,25 @@ test.describe("Timeline", () => {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]',
|
||||
)
|
||||
.first(),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(),
|
||||
row
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||
@@ -2029,13 +2156,14 @@ test.describe("Timeline", () => {
|
||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const mondaySegment = resourceRow.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
).first();
|
||||
const mondaySegment = resourceRow
|
||||
.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
)
|
||||
.first();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
|
||||
const projectRowSelector =
|
||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
const projectRowSelector = `[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`;
|
||||
await switchToProjectView(page, projectRowSelector);
|
||||
|
||||
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
||||
@@ -2072,7 +2200,9 @@ test.describe("Timeline", () => {
|
||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||
|
||||
const projectAllocationAfterReload = page
|
||||
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
|
||||
.locator(
|
||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
|
||||
)
|
||||
.first();
|
||||
await expect(projectAllocationAfterReload).toBeVisible();
|
||||
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
||||
@@ -2093,15 +2223,12 @@ test.describe("Timeline", () => {
|
||||
const scenario = createTimelineDemandScenario(suffix);
|
||||
|
||||
try {
|
||||
await page.goto(
|
||||
`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`,
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
await page.goto(`/timeline?startDate=2026-04-01&days=31&projectIds=${scenario.projectId}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await ensureOpenDemandVisibilityEnabled(page);
|
||||
const demandRowSelector =
|
||||
`[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||
const demandSelector =
|
||||
`${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||
const demandRowSelector = `[data-project-demand-row="true"][data-project-id="${scenario.projectId}"]`;
|
||||
const demandSelector = `${demandRowSelector} [data-timeline-entry-type="demand"][data-allocation-id="${scenario.demandId}"]`;
|
||||
|
||||
await switchToProjectView(page, demandRowSelector);
|
||||
await expect(page.locator(demandSelector)).toBeVisible();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="email"]', "admin@nexus.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
@@ -27,9 +27,9 @@ test.describe("Vacations", () => {
|
||||
|
||||
test("request vacation button is visible", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(
|
||||
page.locator("button", { hasText: /Request Vacation/i }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator("button", { hasText: /Request Vacation/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||
@@ -37,7 +37,9 @@ test.describe("Vacations", () => {
|
||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||
await expect(reqBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
||||
page.getByText(
|
||||
"Your account is not linked to a resource. Please contact an administrator.",
|
||||
),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -57,11 +59,18 @@ test.describe("Vacations", () => {
|
||||
|
||||
test("team calendar tab renders", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("button", { hasText: "Team Calendar" }).or(page.locator("text=Team Calendar")).first().click();
|
||||
await page
|
||||
.locator("button", { hasText: "Team Calendar" })
|
||||
.or(page.locator("text=Team Calendar"))
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(500);
|
||||
// Calendar view should appear
|
||||
await expect(
|
||||
page.locator("table").or(page.locator("[data-calendar]")).or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
||||
page
|
||||
.locator("table")
|
||||
.or(page.locator("[data-calendar]"))
|
||||
.or(page.locator("text=Mon").or(page.locator("text=Week"))),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
@@ -75,11 +84,15 @@ test.describe("Vacations", () => {
|
||||
await expect(filters.nth(2)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
||||
test("vacation request preview excludes regional public holidays from deducted days", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||
|
||||
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i }),
|
||||
).toHaveCount(0);
|
||||
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||
@@ -89,9 +102,13 @@ test.describe("Vacations", () => {
|
||||
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText(
|
||||
"2026-01-06",
|
||||
);
|
||||
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText(
|
||||
"Holiday Calendar",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import nextjsConfig from "@capakraken/eslint-config/nextjs";
|
||||
import nextjsConfig from "@nexus/eslint-config/nextjs";
|
||||
|
||||
/** @type {import("eslint").Linter.FlatConfig[]} */
|
||||
export default [
|
||||
|
||||
@@ -11,16 +11,16 @@ const nextConfig: NextConfig = {
|
||||
"recharts",
|
||||
"date-fns",
|
||||
"framer-motion",
|
||||
"@capakraken/shared",
|
||||
"@nexus/shared",
|
||||
"@react-pdf/renderer",
|
||||
],
|
||||
},
|
||||
transpilePackages: [
|
||||
"@capakraken/api",
|
||||
"@capakraken/db",
|
||||
"@capakraken/engine",
|
||||
"@capakraken/shared",
|
||||
"@capakraken/staffing",
|
||||
"@nexus/api",
|
||||
"@nexus/db",
|
||||
"@nexus/engine",
|
||||
"@nexus/shared",
|
||||
"@nexus/staffing",
|
||||
],
|
||||
typedRoutes: true,
|
||||
eslint: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@capakraken/web",
|
||||
"name": "@nexus/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -13,11 +13,11 @@
|
||||
"test:e2e:email": "playwright test --config playwright.dev.config.ts e2e/dev-system/invite-flow.spec.ts e2e/dev-system/password-reset.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capakraken/api": "workspace:*",
|
||||
"@capakraken/application": "workspace:*",
|
||||
"@capakraken/db": "workspace:*",
|
||||
"@capakraken/engine": "workspace:*",
|
||||
"@capakraken/shared": "workspace:*",
|
||||
"@nexus/api": "workspace:*",
|
||||
"@nexus/application": "workspace:*",
|
||||
"@nexus/db": "workspace:*",
|
||||
"@nexus/engine": "workspace:*",
|
||||
"@nexus/shared": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -34,7 +34,7 @@
|
||||
"dompurify": "^3.4.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"next": "^15.5.15",
|
||||
"next": "^15.5.16",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"otpauth": "^9.5.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -51,8 +51,8 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.3",
|
||||
"@axe-core/playwright": "^4.11.1",
|
||||
"@capakraken/eslint-config": "workspace:*",
|
||||
"@capakraken/tsconfig": "workspace:*",
|
||||
"@nexus/eslint-config": "workspace:*",
|
||||
"@nexus/tsconfig": "workspace:*",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* dev server at localhost:3100 and exercises real dev-DB data.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @capakraken/web exec playwright test --config playwright.dev.config.ts
|
||||
* pnpm --filter @nexus/web exec playwright test --config playwright.dev.config.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Dev server running: pnpm run dev (or docker compose up)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CapaKraken — Resource & Capacity Planning",
|
||||
"short_name": "CapaKraken",
|
||||
"name": "Nexus — Resource & Capacity Planning",
|
||||
"short_name": "Nexus",
|
||||
"description": "Resource planning and project staffing for 3D production",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const CACHE_NAME = "capakraken-v2";
|
||||
const CACHE_NAME = "nexus-v2";
|
||||
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
||||
|
||||
// Offline fallback page (simple inline HTML)
|
||||
@@ -9,7 +9,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CapaKraken - Offline</title>
|
||||
<title>Nexus - Offline</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@@ -31,7 +31,7 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>You are offline</h1>
|
||||
<p>CapaKraken requires an internet connection. Please check your network and try again.</p>
|
||||
<p>Nexus requires an internet connection. Please check your network and try again.</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEdi
|
||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||
|
||||
export const metadata = { title: "Vacation Management — CapaKraken" };
|
||||
export const metadata = { title: "Vacation Management — Nexus" };
|
||||
|
||||
export default function AdminVacationsPage() {
|
||||
return (
|
||||
@@ -10,15 +10,19 @@ export default function AdminVacationsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
|
||||
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und
|
||||
Fallback-Importe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Holiday Calendars
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung,
|
||||
Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<HolidayCalendarEditor />
|
||||
@@ -26,9 +30,12 @@ export default function AdminVacationsPage() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Legacy Batch Import
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die
|
||||
Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||
</p>
|
||||
</div>
|
||||
<PublicHolidayBatch />
|
||||
@@ -36,9 +43,12 @@ export default function AdminVacationsPage() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||
Entitlements
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
|
||||
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional
|
||||
aufgeloest wurden.
|
||||
</p>
|
||||
</div>
|
||||
<EntitlementManager />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { EstimateStatus, type EstimateVersionStatus } from "@capakraken/shared";
|
||||
import { EstimateStatus, type EstimateVersionStatus } from "@nexus/shared";
|
||||
import { clsx } from "clsx";
|
||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
@@ -122,7 +122,8 @@ function EstimateDetailPanel({
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
||||
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
||||
Estimate detail{" "}
|
||||
<InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{estimate.name}
|
||||
@@ -206,7 +207,8 @@ function EstimateDetailPanel({
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
||||
Scope items{" "}
|
||||
<InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||
</div>
|
||||
@@ -239,7 +241,8 @@ function EstimateDetailPanel({
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
||||
Demand lines{" "}
|
||||
<InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||
</div>
|
||||
@@ -345,13 +348,19 @@ function EstimateCard({
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Opportunity{" "}
|
||||
<InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." />
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{estimate.opportunityId ?? "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">
|
||||
Updated{" "}
|
||||
<InfoTooltip content="When this estimate or any of its versions was last modified." />
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{formatDateLong(estimate.updatedAt)}
|
||||
</p>
|
||||
@@ -466,7 +475,7 @@ export function EstimatesClient() {
|
||||
No estimates yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||
Start with the wizard to create a connected estimate from CapaKraken data.
|
||||
Start with the wizard to create a connected estimate from Nexus data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
||||
|
||||
export const metadata = {
|
||||
title: "CapaKraken — Mobile Summary",
|
||||
title: "Nexus — Mobile Summary",
|
||||
};
|
||||
|
||||
export default function MobilePage() {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatDate, formatMoney } from "~/lib/format.js";
|
||||
import type { Project, ColumnDef, ProjectStatus } from "@capakraken/shared";
|
||||
import { PROJECT_COLUMNS, BlueprintTarget } from "@capakraken/shared";
|
||||
import type { Project, ColumnDef, ProjectStatus } from "@nexus/shared";
|
||||
import { PROJECT_COLUMNS, BlueprintTarget } from "@nexus/shared";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useUrlFilters } from "~/hooks/useUrlFilters.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import Link from "next/link";
|
||||
import type { Resource, SkillEntry } from "@capakraken/shared";
|
||||
import { RESOURCE_COLUMNS } from "@capakraken/shared";
|
||||
import { BlueprintTarget, ResourceType } from "@capakraken/shared";
|
||||
import type { Resource, SkillEntry } from "@nexus/shared";
|
||||
import { RESOURCE_COLUMNS } from "@nexus/shared";
|
||||
import { BlueprintTarget, ResourceType } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { generateCsv, downloadCsv } from "~/lib/csv-export.js";
|
||||
@@ -945,7 +945,7 @@ export function ResourcesClient() {
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
tooltip="Unique employee identifier used across all CapaKraken records."
|
||||
tooltip="Unique employee identifier used across all Nexus records."
|
||||
/>
|
||||
);
|
||||
case "displayName":
|
||||
|
||||
@@ -2,24 +2,22 @@ import type { Metadata } from "next";
|
||||
import { createCaller } from "~/server/trpc.js";
|
||||
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const trpc = await createCaller();
|
||||
const resource = await trpc.resource.getById({ id });
|
||||
return { title: `${resource.displayName} — Resources | CapaKraken` };
|
||||
return { title: `${resource.displayName} — Resources | Nexus` };
|
||||
} catch {
|
||||
return { title: "Resource — CapaKraken" };
|
||||
return { title: "Resource — Nexus" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ResourceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
export default async function ResourceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <ResourceDetail resourceId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Resource } from "@capakraken/shared";
|
||||
import { ResourceType } from "@capakraken/shared";
|
||||
import type { Resource } from "@nexus/shared";
|
||||
import { ResourceType } from "@nexus/shared";
|
||||
|
||||
export type ModalState =
|
||||
| { type: "closed" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
||||
|
||||
export const metadata = { title: "My Vacations — CapaKraken" };
|
||||
export const metadata = { title: "My Vacations — Nexus" };
|
||||
|
||||
export default function MyVacationsPage() {
|
||||
return <MyVacationsClient />;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { prisma } from "@nexus/db";
|
||||
|
||||
/** Window over which auth events are analysed. */
|
||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
@@ -17,7 +17,7 @@ import { THRESHOLDS } from "./detect.js";
|
||||
const auditLogFindManyMock = vi.hoisted(() => vi.fn());
|
||||
const userFindManyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@capakraken/db", () => ({
|
||||
vi.mock("@nexus/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { findMany: auditLogFindManyMock },
|
||||
user: { findMany: userFindManyMock },
|
||||
@@ -27,11 +27,11 @@ vi.mock("@capakraken/db", () => ({
|
||||
// ─── createNotificationsForUsers mock ─────────────────────────────────────────
|
||||
const createNotificationsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("@capakraken/api", () => ({
|
||||
vi.mock("@nexus/api", () => ({
|
||||
createNotificationsForUsers: createNotificationsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/api/lib/logger", () => ({
|
||||
vi.mock("@nexus/api/lib/logger", () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("GET /api/cron/auth-anomaly-check — cron secret enforcement", () => {
|
||||
const { GET } = await importRoute();
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
}, 15_000); // next/server cold-import can take >5s on the act runner
|
||||
|
||||
it("proceeds when verifyCronSecret returns null (allowed)", async () => {
|
||||
verifyCronSecretMock.mockReturnValue(null);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { createNotificationsForUsers } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
import { detectAuthAnomalies } from "./detect.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { checkChargeabilityAlerts } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { checkChargeabilityAlerts } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { checkPendingEstimateReminders } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { checkPendingEstimateReminders } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { createNotificationsForUsers } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { createConnection } from "net";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { autoImportPublicHolidays } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { autoImportPublicHolidays } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -45,10 +45,10 @@ export async function GET(request: Request) {
|
||||
skippedExisting: result.skippedExisting,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
logger.error(
|
||||
{ error, route: "/api/cron/public-holidays", year },
|
||||
"Public holiday import cron failed",
|
||||
);
|
||||
return NextResponse.json({ ok: false, error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { createNotificationsForUsers } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { sendWeeklyDigest } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { sendWeeklyDigest } from "@nexus/api";
|
||||
import { logger } from "@nexus/api/lib/logger";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { createConnection } from "net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,8 +30,14 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
||||
socket.destroy();
|
||||
resolve(data.toString().includes("PONG") ? "ok" : "error");
|
||||
});
|
||||
socket.on("timeout", () => { socket.destroy(); resolve("error"); });
|
||||
socket.on("error", () => { socket.destroy(); resolve("error"); });
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve("error");
|
||||
});
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve("error");
|
||||
});
|
||||
} catch {
|
||||
resolve("error");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/api/sse", () => ({
|
||||
vi.mock("@nexus/api/sse", () => ({
|
||||
eventBus: { subscriberCount: 0 },
|
||||
}));
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("GET /api/perf — security hardening", () => {
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown };
|
||||
const body = (await response.json()) as { timestamp: string; uptime: unknown; memory: unknown };
|
||||
expect(typeof body.timestamp).toBe("string");
|
||||
expect(body.uptime).toBeDefined();
|
||||
expect(body.memory).toBeDefined();
|
||||
@@ -81,7 +81,11 @@ describe("GET /api/perf — security hardening", () => {
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
|
||||
const body = (await response.json()) as {
|
||||
error?: string;
|
||||
timestamp?: string;
|
||||
memory?: unknown;
|
||||
};
|
||||
expect(body.timestamp).toBeUndefined();
|
||||
expect(body.memory).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eventBus } from "@capakraken/api/sse";
|
||||
import { eventBus } from "@nexus/api/sse";
|
||||
import { verifyCronSecret } from "~/lib/cron-auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { createConnection } from "net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,7 +18,7 @@ async function checkPostgres(): Promise<"ok" | "error"> {
|
||||
|
||||
/**
|
||||
* Lightweight Redis PING check using a raw TCP socket.
|
||||
* Avoids importing ioredis (which is only a dependency of @capakraken/api).
|
||||
* Avoids importing ioredis (which is only a dependency of @nexus/api).
|
||||
*/
|
||||
async function checkRedis(): Promise<"ok" | "error"> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -58,10 +58,7 @@ async function checkRedis(): Promise<"ok" | "error"> {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [postgres, redis] = await Promise.all([
|
||||
checkPostgres(),
|
||||
checkRedis(),
|
||||
]);
|
||||
const [postgres, redis] = await Promise.all([checkPostgres(), checkRedis()]);
|
||||
|
||||
const allHealthy = postgres === "ok" && redis === "ok";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const authMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("~/server/auth.js", () => ({ auth: authMock }));
|
||||
|
||||
// ─── heavy dep stubs ─────────────────────────────────────────────────────────
|
||||
vi.mock("@capakraken/db", () => ({
|
||||
vi.mock("@nexus/db", () => ({
|
||||
prisma: {
|
||||
demandRequirement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
assignment: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -21,11 +21,11 @@ vi.mock("@capakraken/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
vi.mock("@nexus/application", () => ({
|
||||
buildSplitAllocationReadModel: vi.fn().mockReturnValue({ assignments: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/api", () => ({
|
||||
vi.mock("@nexus/api", () => ({
|
||||
anonymizeResource: vi.fn((r: unknown) => r),
|
||||
getAnonymizationDirectory: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
@@ -2,10 +2,10 @@ import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { buildSplitAllocationReadModel } from "@capakraken/application";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "@capakraken/api";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import type { AllocationLike } from "@capakraken/shared";
|
||||
import { buildSplitAllocationReadModel } from "@nexus/application";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "@nexus/api";
|
||||
import { prisma } from "@nexus/db";
|
||||
import type { AllocationLike } from "@nexus/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
||||
import { createWorkbookArrayBuffer } from "~/lib/workbook-export.js";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { loadRoleDefaults } from "@capakraken/api";
|
||||
import { deriveUserSseSubscription, eventBus } from "@capakraken/api/sse";
|
||||
import { startReminderScheduler } from "@capakraken/api/lib/reminder-scheduler";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import type { SystemRole } from "@capakraken/shared";
|
||||
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@capakraken/shared";
|
||||
import { loadRoleDefaults } from "@nexus/api";
|
||||
import { deriveUserSseSubscription, eventBus } from "@nexus/api/sse";
|
||||
import { startReminderScheduler } from "@nexus/api/lib/reminder-scheduler";
|
||||
import { prisma } from "@nexus/db";
|
||||
import type { SystemRole } from "@nexus/shared";
|
||||
import { SSE_EVENT_TYPES, type PermissionOverrides } from "@nexus/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTRPCContext, loadRoleDefaults } from "@capakraken/api";
|
||||
import { appRouter } from "@capakraken/api/router";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createTRPCContext, loadRoleDefaults } from "@nexus/api";
|
||||
import { appRouter } from "@nexus/api/router";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function SignInPage() {
|
||||
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
||||
CapaKraken Control Center
|
||||
Nexus Control Center
|
||||
</span>
|
||||
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
||||
Resource planning that stays readable under pressure.
|
||||
@@ -137,7 +137,7 @@ export default function SignInPage() {
|
||||
Welcome Back
|
||||
</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{mfaRequired ? "Two-Factor Authentication" : "Sign in to CapaKraken"}
|
||||
{mfaRequired ? "Two-Factor Authentication" : "Sign in to Nexus"}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{mfaRequired
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
@@ -91,7 +91,7 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
|
||||
You have been invited as <strong>{invite.role}</strong> to Nexus. Set a password to
|
||||
activate your account (<span className="font-medium">{invite.email}</span>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+51
-10
@@ -19,8 +19,8 @@ const displayFont = Manrope({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://capakraken.hartmut-noerenberg.com"),
|
||||
title: "CapaKraken — Resource & Capacity Planning",
|
||||
metadataBase: new URL("https://nexus.hartmut-noerenberg.com"),
|
||||
title: "Nexus — Resource & Capacity Planning",
|
||||
description: "Interactive resource planning and project staffing tool",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
@@ -35,17 +35,17 @@ export const metadata: Metadata = {
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "CapaKraken",
|
||||
title: "Nexus",
|
||||
},
|
||||
openGraph: {
|
||||
title: "CapaKraken — Resource & Capacity Planning",
|
||||
title: "Nexus — Resource & Capacity Planning",
|
||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "CapaKraken Logo" }],
|
||||
images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "Nexus Logo" }],
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "CapaKraken — Resource & Capacity Planning",
|
||||
title: "Nexus — Resource & Capacity Planning",
|
||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||
images: ["/og-image.png"],
|
||||
},
|
||||
@@ -60,15 +60,56 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script nonce={nonce} suppressHydrationWarning dangerouslySetInnerHTML={{__html: `
|
||||
<script
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('capakraken_theme') || '{}');
|
||||
if (!localStorage.getItem('nexus_migrated_v1')) {
|
||||
var underscoreKeys = ['theme','sidebar_collapsed','mfa_prompt_snoozed_until','prefs','pwa_dismiss'];
|
||||
underscoreKeys.forEach(function(k){
|
||||
var oldK = 'capakraken_' + k, newK = 'nexus_' + k;
|
||||
var v = localStorage.getItem(oldK);
|
||||
if (v !== null && localStorage.getItem(newK) === null) localStorage.setItem(newK, v);
|
||||
localStorage.removeItem(oldK);
|
||||
});
|
||||
var dashKeys = [];
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var lk = localStorage.key(i);
|
||||
if (lk && lk.indexOf('capakraken_dashboard_v1_') === 0) dashKeys.push(lk);
|
||||
}
|
||||
dashKeys.forEach(function(lk){
|
||||
var newLk = 'nexus_' + lk.substring('capakraken_'.length);
|
||||
var v = localStorage.getItem(lk);
|
||||
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
||||
localStorage.removeItem(lk);
|
||||
});
|
||||
['capakraken-chat-messages','capakraken-chat-conversation-id'].forEach(function(lk){
|
||||
var newLk = 'nexus-' + lk.substring('capakraken-'.length);
|
||||
var v = localStorage.getItem(lk);
|
||||
if (v !== null && localStorage.getItem(newLk) === null) localStorage.setItem(newLk, v);
|
||||
localStorage.removeItem(lk);
|
||||
});
|
||||
var av = localStorage.getItem('capakraken:allocations:viewMode');
|
||||
if (av !== null && localStorage.getItem('nexus:allocations:viewMode') === null) {
|
||||
localStorage.setItem('nexus:allocations:viewMode', av);
|
||||
}
|
||||
localStorage.removeItem('capakraken:allocations:viewMode');
|
||||
localStorage.setItem('nexus_migrated_v1', '1');
|
||||
if (typeof caches !== 'undefined') caches.delete('capakraken-v2');
|
||||
}
|
||||
var p = JSON.parse(localStorage.getItem('nexus_theme') || '{}');
|
||||
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
||||
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
||||
} catch(e) {}
|
||||
`}} />
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
||||
<body
|
||||
className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}
|
||||
>
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
<InstallPrompt />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||
import { createFirstAdmin } from "./actions.js";
|
||||
|
||||
export function SetupClient() {
|
||||
@@ -76,7 +76,7 @@ export function SetupClient() {
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Create the initial administrator account for CapaKraken.
|
||||
Create the initial administrator account for Nexus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { SystemRole } from "@capakraken/db";
|
||||
import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
} from "@capakraken/shared";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { SystemRole } from "@nexus/db";
|
||||
import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@nexus/shared";
|
||||
|
||||
export type SetupResult =
|
||||
| { success: true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { prisma } from "@nexus/db";
|
||||
import { SetupClient } from "./SetupClient.js";
|
||||
|
||||
export default async function SetupPage() {
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useState, useRef } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
||||
import { assertSpreadsheetFile } from "~/lib/excel.js";
|
||||
import type { SkillEntry } from "@capakraken/shared";
|
||||
import type { SkillEntry } from "@nexus/shared";
|
||||
|
||||
interface ParsedEntry {
|
||||
fileName: string;
|
||||
candidateEid: string; // guessed from filename (no extension, lowercased)
|
||||
candidateEid: string; // guessed from filename (no extension, lowercased)
|
||||
selectedEid: string;
|
||||
skills: SkillEntry[];
|
||||
employeeInfo: Record<string, string>;
|
||||
@@ -30,8 +30,14 @@ export function BatchSkillImport() {
|
||||
);
|
||||
|
||||
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
||||
onSuccess: (data) => { setResult(data); setSubmitting(false); },
|
||||
onError: (err) => { setError(err.message); setSubmitting(false); },
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setSubmitting(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
setSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -72,7 +78,8 @@ export function BatchSkillImport() {
|
||||
|
||||
const empInfo: Record<string, string> = {};
|
||||
if (roleId) empInfo["roleId"] = roleId;
|
||||
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
||||
if (result.employeeInfo.portfolioUrl)
|
||||
empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
||||
|
||||
return {
|
||||
fileName: file.name,
|
||||
@@ -124,7 +131,9 @@ export function BatchSkillImport() {
|
||||
skills: e.skills,
|
||||
employeeInfo: {
|
||||
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
||||
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
|
||||
...(e.employeeInfo["portfolioUrl"]
|
||||
? { portfolioUrl: e.employeeInfo["portfolioUrl"] }
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
});
|
||||
@@ -138,7 +147,9 @@ export function BatchSkillImport() {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Batch Skill Matrix Import
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
||||
</p>
|
||||
@@ -149,12 +160,33 @@ export function BatchSkillImport() {
|
||||
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
<svg
|
||||
className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
|
||||
<input ref={fileRef} type="file" accept=".xlsx" multiple className="hidden" onChange={handleFiles} />
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Click to select multiple .xlsx files
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Name files after resource EID or display name for automatic matching
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
@@ -166,7 +198,9 @@ export function BatchSkillImport() {
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
||||
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400 ml-1">
|
||||
unmatched (select EID manually)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -177,20 +211,39 @@ export function BatchSkillImport() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
File
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Resource EID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Skills
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Role Match
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((entry, idx) => (
|
||||
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
|
||||
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""
|
||||
}
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">
|
||||
{entry.fileName}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.status === "matched" ? (
|
||||
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
|
||||
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">
|
||||
{entry.selectedEid}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
@@ -199,17 +252,27 @@ export function BatchSkillImport() {
|
||||
>
|
||||
<option value="">— Select resource —</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
|
||||
<option key={r.eid} value={r.eid}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">
|
||||
{entry.skills.length}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.matchedRoleName ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
||||
}`}>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
entry.status === "matched"
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
@@ -221,12 +284,15 @@ export function BatchSkillImport() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
|
||||
Import complete: <strong>{result.updated}</strong> updated,{" "}
|
||||
<strong>{result.notFound}</strong> not found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -237,7 +303,9 @@ export function BatchSkillImport() {
|
||||
disabled={submitting || matched === 0}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
||||
{submitting
|
||||
? "Importing…"
|
||||
: `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { SystemRole } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
|
||||
@@ -51,7 +51,10 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!email) { setError("Email is required."); return; }
|
||||
if (!email) {
|
||||
setError("Email is required.");
|
||||
return;
|
||||
}
|
||||
await inviteMutation.mutateAsync({ email, role });
|
||||
}
|
||||
|
||||
@@ -96,7 +99,9 @@ export function InviteUserModal({ open, onClose }: InviteUserModalProps) {
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
>
|
||||
{ROLE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
import { PermissionKey } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
||||
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
|
||||
import { PASSWORD_MIN_LENGTH, SystemRole } from "@nexus/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@capakraken/shared";
|
||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@nexus/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import type { PermissionKey } from "@capakraken/shared";
|
||||
import type { PermissionKey } from "@nexus/shared";
|
||||
import {
|
||||
SystemRole,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
MILLISECONDS_PER_DAY,
|
||||
type PermissionOverrides,
|
||||
} from "@capakraken/shared";
|
||||
} from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { InviteUserModal } from "./InviteUserModal.js";
|
||||
|
||||
@@ -176,7 +176,7 @@ export function WebhooksClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure outbound webhooks to notify external services about events in CapaKraken.
|
||||
Configure outbound webhooks to notify external services about events in Nexus.
|
||||
</p>
|
||||
</div>
|
||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||
@@ -194,10 +194,7 @@ export function WebhooksClient() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className="app-surface flex items-center gap-4 p-4"
|
||||
>
|
||||
<div key={wh.id} className="app-surface flex items-center gap-4 p-4">
|
||||
{/* Active indicator */}
|
||||
<div
|
||||
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||
@@ -209,9 +206,7 @@ export function WebhooksClient() {
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{wh.name}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{wh.name}</span>
|
||||
{wh.url.includes("hooks.slack.com") && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
Slack
|
||||
@@ -257,17 +252,12 @@ export function WebhooksClient() {
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() =>
|
||||
handleToggleActive(wh.id, wh.isActive)
|
||||
}
|
||||
onClick={() => handleToggleActive(wh.id, wh.isActive)}
|
||||
disabled={updateMut.isPending}
|
||||
>
|
||||
{wh.isActive ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => openEditModal(wh)}
|
||||
>
|
||||
<button className={SECONDARY_BUTTON} onClick={() => openEditModal(wh)}>
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirmId === wh.id ? (
|
||||
@@ -282,18 +272,12 @@ export function WebhooksClient() {
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(wh.id)}
|
||||
>
|
||||
<button className={SECONDARY_BUTTON} onClick={() => setDeleteConfirmId(wh.id)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
@@ -335,9 +319,7 @@ export function WebhooksClient() {
|
||||
|
||||
{/* Secret */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
Secret (optional)
|
||||
</label>
|
||||
<label className={LABEL_CLASS}>Secret (optional)</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
type="password"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
||||
import { DEFAULT_OPENAI_MODEL } from "@nexus/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
@@ -123,7 +123,9 @@ export function AiProviderPanel({
|
||||
</p>
|
||||
) : null}
|
||||
{urlParsedType === "completions" ? (
|
||||
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-400">
|
||||
All fields filled from URL.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +156,7 @@ export function AiProviderPanel({
|
||||
id="ai-model"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={provider === "azure" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
||||
placeholder={provider === "azure" ? "nexus-gpt-5-4" : DEFAULT_OPENAI_MODEL}
|
||||
value={model}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
/>
|
||||
@@ -223,12 +225,7 @@ export function AiProviderPanel({
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
<button
|
||||
@@ -389,12 +386,7 @@ export function GenerationSettingsPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
<button type="button" onClick={onSave} disabled={isSaving} className={PRIMARY_BUTTON_CLASS}>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
{saved ? (
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSett
|
||||
className={INPUT_CLASS}
|
||||
value={smtpFrom}
|
||||
onChange={(event) => setSmtpFrom(event.target.value)}
|
||||
placeholder="noreply@capakraken.app"
|
||||
placeholder="noreply@nexus.app"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${CHECKBOX_ROW_CLASS} pt-0 md:mt-[1.65rem]`}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllocationWithDetails, AllocationStatus } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails, AllocationStatus } from "@nexus/shared";
|
||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
||||
import type { CollapsedAllocationGroups } from "./allocationGroupState.js";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { AllocationRow } from "./AllocationRow.js";
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@capakraken/shared";
|
||||
import { AllocationStatus } from "@nexus/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { toDateInputValue } from "~/lib/format.js";
|
||||
@@ -26,7 +26,8 @@ interface AllocationModalProps {
|
||||
|
||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||
const isEditing = Boolean(allocation);
|
||||
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const initialEntryKind: EntryKind =
|
||||
allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||
const isDemandEntry = entryKind === "demand";
|
||||
|
||||
@@ -57,14 +58,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
|
||||
const { data: rolesData } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
|
||||
// Fetch existing allocations for the selected resource+project to detect overlaps
|
||||
const shouldCheckOverlap = !isDemandEntry && !!resourceId && !!projectId;
|
||||
@@ -85,20 +80,26 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
const shouldCheckConflicts =
|
||||
!isDemandEntry &&
|
||||
!!debouncedResourceId &&
|
||||
conflictCheckStart !== null && !isNaN(conflictCheckStart.getTime()) &&
|
||||
conflictCheckEnd !== null && !isNaN(conflictCheckEnd.getTime()) &&
|
||||
conflictCheckStart !== null &&
|
||||
!isNaN(conflictCheckStart.getTime()) &&
|
||||
conflictCheckEnd !== null &&
|
||||
!isNaN(conflictCheckEnd.getTime()) &&
|
||||
debouncedHoursPerDay > 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: conflictResult, isFetching: checkingConflicts } = (trpc.allocation.checkConflicts.useQuery as any)(
|
||||
{
|
||||
resourceId: debouncedResourceId,
|
||||
startDate: conflictCheckStart,
|
||||
endDate: conflictCheckEnd,
|
||||
hoursPerDay: debouncedHoursPerDay,
|
||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||
},
|
||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||
) as { data: import("@capakraken/shared").AllocationConflictCheckResult | undefined; isFetching: boolean };
|
||||
const { data: conflictResult, isFetching: checkingConflicts } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(trpc.allocation.checkConflicts.useQuery as any)(
|
||||
{
|
||||
resourceId: debouncedResourceId,
|
||||
startDate: conflictCheckStart,
|
||||
endDate: conflictCheckEnd,
|
||||
hoursPerDay: debouncedHoursPerDay,
|
||||
excludeAssignmentId: isEditing && allocation?.id ? allocation.id : undefined,
|
||||
},
|
||||
{ enabled: shouldCheckConflicts, staleTime: 15_000 },
|
||||
) as {
|
||||
data: import("@nexus/shared").AllocationConflictCheckResult | undefined;
|
||||
isFetching: boolean;
|
||||
};
|
||||
|
||||
const overlapWarning = useMemo(() => {
|
||||
if (!shouldCheckOverlap || !existingAllocations || !startDate || !endDate) return null;
|
||||
@@ -106,7 +107,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
const formEnd = new Date(endDate);
|
||||
if (isNaN(formStart.getTime()) || isNaN(formEnd.getTime())) return null;
|
||||
|
||||
const allocList = (existingAllocations as { allocations?: Array<{ id: string; resourceId?: string | null; startDate: string | Date; endDate: string | Date }> }).allocations ?? [];
|
||||
const allocList =
|
||||
(
|
||||
existingAllocations as {
|
||||
allocations?: Array<{
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
}>;
|
||||
}
|
||||
).allocations ?? [];
|
||||
for (const existing of allocList) {
|
||||
// Skip the allocation being edited
|
||||
if (isEditing && allocation && existing.id === allocation.id) continue;
|
||||
@@ -121,7 +132,15 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [shouldCheckOverlap, existingAllocations, startDate, endDate, isEditing, allocation, resourceId]);
|
||||
}, [
|
||||
shouldCheckOverlap,
|
||||
existingAllocations,
|
||||
startDate,
|
||||
endDate,
|
||||
isEditing,
|
||||
allocation,
|
||||
resourceId,
|
||||
]);
|
||||
|
||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||
|
||||
@@ -185,7 +204,17 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
useEffect(() => {
|
||||
setServerError(null);
|
||||
setOverbookingAcknowledged(false);
|
||||
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
||||
}, [
|
||||
resourceId,
|
||||
projectId,
|
||||
roleId,
|
||||
roleFreeText,
|
||||
startDate,
|
||||
endDate,
|
||||
hoursPerDay,
|
||||
status,
|
||||
entryKind,
|
||||
]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -222,7 +251,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
// Determine role string from roleId if set
|
||||
const rolesList = rolesData ?? [];
|
||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
||||
const roleString = selectedRole ? selectedRole.name : roleFreeText || undefined;
|
||||
|
||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||
|
||||
@@ -230,12 +259,14 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
updateMutation.mutate({
|
||||
id: getPlanningEntryMutationId(allocation),
|
||||
data: {
|
||||
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
||||
resourceId: isDemandEntry ? undefined : resourceId || undefined,
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount: isDemandEntry ? headcount : 1,
|
||||
...(isDemandEntry && budgetEur ? { budgetCents: Math.round(parseFloat(budgetEur) * 100) } : {}),
|
||||
...(isDemandEntry && budgetEur
|
||||
? { budgetCents: Math.round(parseFloat(budgetEur) * 100) }
|
||||
: {}),
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
@@ -279,18 +310,22 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
||||
const resourceList = (resources?.resources ?? []) as Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
}>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
}>;
|
||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||
|
||||
return (
|
||||
<AnimatedModal open={true} onClose={onClose} maxWidth="max-w-xl" className="mx-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="allocation-modal"
|
||||
>
|
||||
<div role="dialog" aria-modal="true" data-testid="allocation-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
@@ -333,7 +368,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{isDemandEntry && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Headcount:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={headcount}
|
||||
@@ -344,7 +381,9 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Budget (EUR):</label>
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Budget (EUR):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={budgetEur}
|
||||
@@ -363,7 +402,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{!isDemandEntry && (
|
||||
<div>
|
||||
<label htmlFor="modal-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||
Resource <span className="text-red-500">*</span>
|
||||
<InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-resource"
|
||||
@@ -385,7 +425,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label htmlFor="modal-project" className={labelClass}>
|
||||
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||
Project <span className="text-red-500">*</span>
|
||||
<InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-project"
|
||||
@@ -405,7 +446,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
|
||||
<label htmlFor="modal-role" className={labelClass}>
|
||||
Role
|
||||
<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-role"
|
||||
value={roleId}
|
||||
@@ -434,35 +478,43 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{/* Dates */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={labelClass}>Date Range <span className="text-red-500">*</span></span>
|
||||
<DateRangePresets onSelect={(s, e) => { setStartDate(s); setEndDate(e); }} />
|
||||
<span className={labelClass}>
|
||||
Date Range <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<DateRangePresets
|
||||
onSelect={(s, e) => {
|
||||
setStartDate(s);
|
||||
setEndDate(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date{" "}
|
||||
<InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -470,7 +522,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-hours" className={labelClass}>
|
||||
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||
Hours / Day
|
||||
<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||
</label>
|
||||
<input
|
||||
id="modal-hours"
|
||||
@@ -485,7 +538,8 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" className={labelClass}>
|
||||
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||
Status
|
||||
<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-status"
|
||||
@@ -514,7 +568,10 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Recurring schedule
|
||||
</span>
|
||||
<InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||
</label>
|
||||
{isRecurring && (
|
||||
<div className="mt-2">
|
||||
@@ -548,7 +605,12 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
)}
|
||||
{!conflictResult && checkingConflicts && (
|
||||
<ConflictWarningPanel
|
||||
result={{ isOverbooking: false, overbooking: null, vacationOverlap: [], hasVacationOverlap: false }}
|
||||
result={{
|
||||
isOverbooking: false,
|
||||
overbooking: null,
|
||||
vacationOverlap: [],
|
||||
hasVacationOverlap: false,
|
||||
}}
|
||||
isLoading={true}
|
||||
acknowledged={false}
|
||||
onAcknowledge={() => {}}
|
||||
@@ -568,7 +630,11 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || hasUnacknowledgedOverbooking}
|
||||
title={hasUnacknowledgedOverbooking ? "Acknowledge the overbooking warning above to proceed" : undefined}
|
||||
title={
|
||||
hasUnacknowledgedOverbooking
|
||||
? "Acknowledge the overbooking warning above to proceed"
|
||||
: undefined
|
||||
}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllocationWithDetails, ColumnDef } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails, ColumnDef } from "@nexus/shared";
|
||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||
|
||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
AllocationWithDetails,
|
||||
ColumnDef,
|
||||
AllocationStatus,
|
||||
} from "@capakraken/shared";
|
||||
import { ALLOCATION_COLUMNS } from "@capakraken/shared";
|
||||
} from "@nexus/shared";
|
||||
import { ALLOCATION_COLUMNS } from "@nexus/shared";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
@@ -328,7 +328,7 @@ export function AllocationsClient() {
|
||||
|
||||
// ─── View mode: grouped (default) vs flat ──────────────────────────────────
|
||||
const [viewMode, setViewMode] = useLocalStorage<"grouped" | "flat">(
|
||||
"capakraken:allocations:viewMode",
|
||||
"nexus:allocations:viewMode",
|
||||
"grouped",
|
||||
);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<CollapsedAllocationGroups>(() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { AllocationConflictCheckResult } from "@capakraken/shared";
|
||||
import type { AllocationConflictCheckResult } from "@nexus/shared";
|
||||
|
||||
const INITIAL_ROWS_SHOWN = 5;
|
||||
|
||||
@@ -43,12 +43,12 @@ export function ConflictWarningPanel({
|
||||
<div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm">
|
||||
<p className="font-semibold text-amber-800 dark:text-amber-300">
|
||||
⚠ Overbooking on {result.overbooking.totalConflictDays} day
|
||||
{result.overbooking.totalConflictDays !== 1 ? "s" : ""}
|
||||
{" "}(up to {result.overbooking.maxOverbookPercent}% over capacity)
|
||||
{result.overbooking.totalConflictDays !== 1 ? "s" : ""} (up to{" "}
|
||||
{result.overbooking.maxOverbookPercent}% over capacity)
|
||||
</p>
|
||||
<p className="mt-1 text-amber-700 dark:text-amber-400">
|
||||
The resource already has allocations that exceed their daily capacity on the following days.
|
||||
You can still save — check the box below to confirm.
|
||||
The resource already has allocations that exceed their daily capacity on the following
|
||||
days. You can still save — check the box below to confirm.
|
||||
</p>
|
||||
|
||||
{/* Day-by-day table */}
|
||||
@@ -65,7 +65,10 @@ export function ConflictWarningPanel({
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleDays.map((day) => (
|
||||
<tr key={day.date} className="border-b border-amber-100 dark:border-amber-900/50 last:border-0">
|
||||
<tr
|
||||
key={day.date}
|
||||
className="border-b border-amber-100 dark:border-amber-900/50 last:border-0"
|
||||
>
|
||||
<td className="py-1 pr-4">{day.date}</td>
|
||||
<td className="py-1 pr-4 text-right">{day.availableHours}h</td>
|
||||
<td className="py-1 pr-4 text-right">{day.existingHours}h</td>
|
||||
@@ -85,7 +88,9 @@ export function ConflictWarningPanel({
|
||||
onClick={() => setShowAllDays((v) => !v)}
|
||||
className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400 underline underline-offset-2"
|
||||
>
|
||||
{showAllDays ? "Show less" : `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
||||
{showAllDays
|
||||
? "Show less"
|
||||
: `Show ${hiddenCount} more day${hiddenCount !== 1 ? "s" : ""}…`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -115,11 +120,18 @@ export function ConflictWarningPanel({
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{result.vacationOverlap.map((v, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400">
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-xs text-sky-700 dark:text-sky-400"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-400" />
|
||||
<span className="font-medium capitalize">{v.type.replace(/_/g, " ").toLowerCase()}</span>
|
||||
<span className="font-medium capitalize">
|
||||
{v.type.replace(/_/g, " ").toLowerCase()}
|
||||
</span>
|
||||
{v.isHalfDay && <span className="text-sky-500">(half-day)</span>}
|
||||
<span>{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}</span>
|
||||
<span>
|
||||
{v.startDate === v.endDate ? v.startDate : `${v.startDate} – ${v.endDate}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import { AllocationStatus } from "@nexus/shared";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
@@ -75,7 +75,11 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
const { data: resources } = trpc.resource.listStaff.useQuery(
|
||||
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
||||
{ staleTime: 15_000 },
|
||||
) as { data: { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> } | undefined };
|
||||
) as {
|
||||
data:
|
||||
| { resources: Array<{ id: string; displayName: string; eid: string; lcrCents: number }> }
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const availabilityQuery = trpc.allocation.checkResourceAvailability.useQuery(
|
||||
{
|
||||
@@ -118,17 +122,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
const lcrCents = selectedResource.lcrCents ?? 0;
|
||||
const estimatedCostCents = Math.round(lcrCents * avail.totalAvailableHours);
|
||||
|
||||
setPlanned((prev) => [...prev, {
|
||||
resourceId: selectedResource.id,
|
||||
resourceName: selectedResource.displayName,
|
||||
eid: selectedResource.eid,
|
||||
hoursPerDay,
|
||||
availableHours: avail.totalAvailableHours,
|
||||
availableDays: avail.availableDays,
|
||||
conflictDays: avail.conflictDays,
|
||||
coveragePercent: avail.coveragePercent,
|
||||
estimatedCostCents,
|
||||
}]);
|
||||
setPlanned((prev) => [
|
||||
...prev,
|
||||
{
|
||||
resourceId: selectedResource.id,
|
||||
resourceName: selectedResource.displayName,
|
||||
eid: selectedResource.eid,
|
||||
hoursPerDay,
|
||||
availableHours: avail.totalAvailableHours,
|
||||
availableDays: avail.availableDays,
|
||||
conflictDays: avail.conflictDays,
|
||||
coveragePercent: avail.coveragePercent,
|
||||
estimatedCostCents,
|
||||
},
|
||||
]);
|
||||
|
||||
// Reset for next resource
|
||||
setResourceId("");
|
||||
@@ -160,7 +167,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
status: AllocationStatus.PROPOSED,
|
||||
});
|
||||
} catch (err) {
|
||||
setServerError(`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setServerError(
|
||||
`Failed to assign ${p.resourceName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -177,12 +186,16 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !submitting) onClose(); }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !submitting) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape" && !submitting) onClose(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && !submitting) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
@@ -190,21 +203,34 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
||||
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">×</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 className="px-6 pt-4 pb-2 space-y-3">
|
||||
{/* Demand summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 flex items-start gap-3">
|
||||
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
|
||||
style={{ backgroundColor: roleColor }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} – {formatDateMedium(allocation.endDate)}
|
||||
{allocation.project?.name} · {formatDateMedium(allocation.startDate)} –{" "}
|
||||
{formatDateMedium(allocation.endDate)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
||||
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
|
||||
{allocation.budgetCents && allocation.budgetCents > 0
|
||||
? ` · Budget: ${formatCents(allocation.budgetCents)} EUR`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +239,10 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1.5">
|
||||
<span>Demand coverage</span>
|
||||
<span>{Math.round(consumedHours)}h / {totalDemandHours}h ({totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)</span>
|
||||
<span>
|
||||
{Math.round(consumedHours)}h / {totalDemandHours}h (
|
||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
||||
{planned.map((r, i) => (
|
||||
@@ -234,11 +263,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<div className="mt-2 space-y-1">
|
||||
{planned.map((r, i) => (
|
||||
<div key={r.resourceId} className="flex items-center gap-2 text-xs group">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }} />
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{r.resourceName}</span>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: `hsl(${(i * 60 + 200) % 360}, 60%, 55%)` }}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
{r.resourceName}
|
||||
</span>
|
||||
<span className="text-gray-400">({r.eid})</span>
|
||||
<span className="text-gray-500">{r.hoursPerDay}h/day</span>
|
||||
<span className="ml-auto text-gray-500">{Math.round(r.availableHours)}h · {r.coveragePercent}%</span>
|
||||
<span className="ml-auto text-gray-500">
|
||||
{Math.round(r.availableHours)}h · {r.coveragePercent}%
|
||||
</span>
|
||||
{phase === "plan" && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -254,7 +290,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{remainingHours > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">Remaining: {Math.round(remainingHours)}h</span>
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">
|
||||
Remaining: {Math.round(remainingHours)}h
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -266,7 +304,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{phase === "plan" && (
|
||||
<div className="px-6 pb-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search Resource</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search Resource
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or EID..."
|
||||
@@ -277,7 +317,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Resource</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Select Resource
|
||||
</label>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
@@ -297,7 +339,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Hours / Day
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
@@ -311,41 +355,53 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
|
||||
{/* Availability preview */}
|
||||
{resourceId && avail && (
|
||||
<div className={`rounded-lg p-3 border text-sm ${
|
||||
avail.coveragePercent >= 100
|
||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
: avail.coveragePercent >= 50
|
||||
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
||||
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||
}`}>
|
||||
<div
|
||||
className={`rounded-lg p-3 border text-sm ${
|
||||
avail.coveragePercent >= 100
|
||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
: avail.coveragePercent >= 50
|
||||
? "bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-800"
|
||||
: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1.5">
|
||||
Availability: {avail.resource.name}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Available</span>
|
||||
<div className="font-semibold text-green-700 dark:text-green-400">{avail.availableDays} days</div>
|
||||
<div className="font-semibold text-green-700 dark:text-green-400">
|
||||
{avail.availableDays} days
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Conflicts</span>
|
||||
<div className="font-semibold text-red-700 dark:text-red-400">{avail.conflictDays} days</div>
|
||||
<div className="font-semibold text-red-700 dark:text-red-400">
|
||||
{avail.conflictDays} days
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Hours</span>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">{avail.totalAvailableHours}h / {avail.totalRequestedHours}h</div>
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{avail.totalAvailableHours}h / {avail.totalRequestedHours}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{avail.existingAssignments.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Existing bookings:</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Existing bookings:
|
||||
</div>
|
||||
{avail.existingAssignments.slice(0, 4).map((a, i) => (
|
||||
<div key={i} className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{a.code} · {a.hoursPerDay}h/day · {a.start} – {a.end}
|
||||
</div>
|
||||
))}
|
||||
{avail.existingAssignments.length > 4 && (
|
||||
<div className="text-xs text-gray-400">+{avail.existingAssignments.length - 4} more</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
+{avail.existingAssignments.length - 4} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -353,12 +409,18 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
)}
|
||||
|
||||
{resourceId && availabilityQuery.isLoading && (
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">Checking availability...</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 animate-pulse">
|
||||
Checking availability...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -391,11 +453,27 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
h/day
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Hours
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Est. Cost
|
||||
<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Coverage
|
||||
<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
@@ -405,11 +483,19 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{r.resourceName}
|
||||
<span className="ml-1 text-xs text-gray-400 font-mono">{r.eid}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||
{r.hoursPerDay}h
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||
{Math.round(r.availableHours)}h
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">
|
||||
{formatCents(r.estimatedCostCents)} EUR
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
|
||||
<span
|
||||
className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}
|
||||
>
|
||||
{r.coveragePercent}%
|
||||
</span>
|
||||
</td>
|
||||
@@ -418,7 +504,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">Total</td>
|
||||
<td className="px-3 py-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
Total
|
||||
</td>
|
||||
<td className="px-3 py-2" />
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{Math.round(consumedHours)}h / {totalDemandHours}h
|
||||
@@ -427,12 +515,20 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
|
||||
{totalDemandHours > 0
|
||||
? Math.round((consumedHours / totalDemandHours) * 100)
|
||||
: 0}
|
||||
%
|
||||
</td>
|
||||
</tr>
|
||||
{allocation.budgetCents && allocation.budgetCents > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Role Budget:
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{formatCents(allocation.budgetCents)} EUR
|
||||
</td>
|
||||
@@ -441,8 +537,12 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
const totalCost = planned.reduce((s, r) => s + r.estimatedCostCents, 0);
|
||||
const remain = allocation.budgetCents! - totalCost;
|
||||
return (
|
||||
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
|
||||
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
|
||||
<span
|
||||
className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}
|
||||
>
|
||||
{remain < 0
|
||||
? `${formatCents(Math.abs(remain))} over`
|
||||
: `${formatCents(remain)} left`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
@@ -455,7 +555,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
|
||||
{remainingHours > 0 && (
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2 border border-amber-200 dark:border-amber-800">
|
||||
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign partially.
|
||||
{Math.round(remainingHours)}h remain uncovered. You can add more resources or assign
|
||||
partially.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -486,7 +587,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
disabled={submitting || planned.length === 0}
|
||||
className="px-5 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-semibold disabled:opacity-50"
|
||||
>
|
||||
{submitting ? `Assigning ${submitProgress}/${planned.length}...` : `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
||||
{submitting
|
||||
? `Assigning ${submitProgress}/${planned.length}...`
|
||||
: `Confirm & Assign ${planned.length} Resource${planned.length !== 1 ? "s" : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllocationWithDetails } from "@capakraken/shared";
|
||||
import type { AllocationWithDetails } from "@nexus/shared";
|
||||
|
||||
type DemandRow = AllocationWithDetails & {
|
||||
sourceAllocationId?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RecurrenceFrequency } from "@capakraken/shared";
|
||||
import type { RecurrencePattern } from "@capakraken/shared";
|
||||
import { RecurrenceFrequency } from "@nexus/shared";
|
||||
import type { RecurrencePattern } from "@nexus/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -39,7 +39,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Frequency selector */}
|
||||
<div>
|
||||
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
|
||||
<span className={labelClass}>
|
||||
Frequency
|
||||
<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." />
|
||||
</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.values(RecurrenceFrequency).map((f) => (
|
||||
<button
|
||||
@@ -55,10 +58,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
{f === RecurrenceFrequency.WEEKLY
|
||||
? "Weekly"
|
||||
: f === RecurrenceFrequency.BIWEEKLY
|
||||
? "Biweekly"
|
||||
: f === RecurrenceFrequency.MONTHLY
|
||||
? "Monthly"
|
||||
: "Custom"}
|
||||
? "Biweekly"
|
||||
: f === RecurrenceFrequency.MONTHLY
|
||||
? "Monthly"
|
||||
: "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -67,7 +70,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||
<div>
|
||||
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
|
||||
<span className={labelClass}>
|
||||
Days of week
|
||||
<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." />
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAY_LABELS.map((label, dow) => {
|
||||
const selected = (value?.weekdays ?? []).includes(dow);
|
||||
@@ -139,7 +145,10 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
|
||||
<label className={labelClass}>
|
||||
Hours per recurring day (optional override)
|
||||
<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
|
||||
@@ -71,8 +71,8 @@ interface AssistantInsight {
|
||||
sections?: AssistantInsightSection[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "capakraken-chat-messages";
|
||||
const CONVERSATION_ID_KEY = "capakraken-chat-conversation-id";
|
||||
const STORAGE_KEY = "nexus-chat-messages";
|
||||
const CONVERSATION_ID_KEY = "nexus-chat-conversation-id";
|
||||
|
||||
function isAssistantApproval(value: unknown): value is AssistantApproval {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { FieldType } from "@capakraken/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
||||
import { FieldType } from "@nexus/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||
import { FieldCard } from "./FieldCard.js";
|
||||
@@ -48,10 +48,7 @@ interface FieldState {
|
||||
// Helpers: Convert between FieldState and BlueprintFieldDefinition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fieldDefToState(
|
||||
def: BlueprintFieldDefinition,
|
||||
target: BlueprintTargetValue,
|
||||
): FieldState {
|
||||
function fieldDefToState(def: BlueprintFieldDefinition, target: BlueprintTargetValue): FieldState {
|
||||
const catalogField = findCatalogField(target, def.key);
|
||||
if (catalogField) {
|
||||
return {
|
||||
@@ -186,9 +183,7 @@ export function BlueprintFieldCatalog({
|
||||
// Build initial state from existing fieldDefs + catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [catalogOverrides, setCatalogOverrides] = useState<
|
||||
Record<string, FieldOverrides>
|
||||
>(() => {
|
||||
const [catalogOverrides, setCatalogOverrides] = useState<Record<string, FieldOverrides>>(() => {
|
||||
const map: Record<string, FieldOverrides> = {};
|
||||
// Start with all catalog fields disabled
|
||||
for (const cf of catalog) {
|
||||
@@ -269,21 +264,13 @@ export function BlueprintFieldCatalog({
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCatalogFieldChange = useCallback(
|
||||
(key: string, overrides: FieldOverrides) => {
|
||||
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleCatalogFieldChange = useCallback((key: string, overrides: FieldOverrides) => {
|
||||
setCatalogOverrides((prev) => ({ ...prev, [key]: overrides }));
|
||||
}, []);
|
||||
|
||||
const handleCustomFieldChange = useCallback(
|
||||
(idx: number, overrides: FieldOverrides) => {
|
||||
setCustomFields((prev) =>
|
||||
prev.map((f, i) => (i === idx ? { ...f, overrides } : f)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleCustomFieldChange = useCallback((idx: number, overrides: FieldOverrides) => {
|
||||
setCustomFields((prev) => prev.map((f, i) => (i === idx ? { ...f, overrides } : f)));
|
||||
}, []);
|
||||
|
||||
function removeCustomField(idx: number) {
|
||||
setCustomFields((prev) => prev.filter((_, i) => i !== idx));
|
||||
@@ -370,9 +357,7 @@ export function BlueprintFieldCatalog({
|
||||
// Collapsed categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
setCollapsedCategories((prev) => {
|
||||
@@ -502,15 +487,16 @@ export function BlueprintFieldCatalog({
|
||||
{/* Field cards */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
|
||||
{categories
|
||||
.filter(
|
||||
(cat) =>
|
||||
activeCategory === null ||
|
||||
activeCategory === cat.name,
|
||||
)
|
||||
.filter((cat) => activeCategory === null || activeCategory === cat.name)
|
||||
.map((cat) => {
|
||||
const fields = fieldsByCategory.get(cat.name) ?? [];
|
||||
if (fields.length === 0 && searchQuery.trim()) return null;
|
||||
if (fields.length === 0 && activeCategory !== null && activeCategory !== cat.name) return null;
|
||||
if (
|
||||
fields.length === 0 &&
|
||||
activeCategory !== null &&
|
||||
activeCategory !== cat.name
|
||||
)
|
||||
return null;
|
||||
|
||||
const isCollapsed = collapsedCategories.has(cat.name);
|
||||
|
||||
@@ -527,9 +513,7 @@ export function BlueprintFieldCatalog({
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">
|
||||
{cat.description}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{cat.description}</span>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
@@ -538,9 +522,7 @@ export function BlueprintFieldCatalog({
|
||||
key={field.key}
|
||||
field={field}
|
||||
overrides={catalogOverrides[field.key]!}
|
||||
onChange={(ov) =>
|
||||
handleCatalogFieldChange(field.key, ov)
|
||||
}
|
||||
onChange={(ov) => handleCatalogFieldChange(field.key, ov)}
|
||||
/>
|
||||
))}
|
||||
{fields.length === 0 && (
|
||||
@@ -555,8 +537,7 @@ export function BlueprintFieldCatalog({
|
||||
})}
|
||||
|
||||
{/* Custom Fields section */}
|
||||
{(activeCategory === null ||
|
||||
activeCategory === "Custom Fields") && (
|
||||
{(activeCategory === null || activeCategory === "Custom Fields") && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -564,9 +545,7 @@ export function BlueprintFieldCatalog({
|
||||
className="flex items-center gap-2 mb-3 w-full text-left group"
|
||||
>
|
||||
<span className="text-xs text-gray-400 transition-transform group-hover:text-gray-600">
|
||||
{collapsedCategories.has("Custom Fields")
|
||||
? "\u25B6"
|
||||
: "\u25BC"}
|
||||
{collapsedCategories.has("Custom Fields") ? "\u25B6" : "\u25BC"}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
Custom Fields
|
||||
@@ -585,8 +564,7 @@ export function BlueprintFieldCatalog({
|
||||
label: cf.custom.label,
|
||||
type: cf.custom.type,
|
||||
category: "Custom Fields",
|
||||
description:
|
||||
cf.overrides.description || "Custom field",
|
||||
description: cf.overrides.description || "Custom field",
|
||||
...(cf.custom.options.length > 0
|
||||
? { options: cf.custom.options }
|
||||
: {}),
|
||||
@@ -597,9 +575,7 @@ export function BlueprintFieldCatalog({
|
||||
<FieldCard
|
||||
field={pseudoCatalog}
|
||||
overrides={cf.overrides}
|
||||
onChange={(ov) =>
|
||||
handleCustomFieldChange(idx, ov)
|
||||
}
|
||||
onChange={(ov) => handleCustomFieldChange(idx, ov)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -619,19 +595,13 @@ export function BlueprintFieldCatalog({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Key{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customKey}
|
||||
onChange={(e) =>
|
||||
setCustomKey(
|
||||
e.target.value.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
"",
|
||||
),
|
||||
)
|
||||
setCustomKey(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))
|
||||
}
|
||||
placeholder="field_key"
|
||||
className="app-input font-mono"
|
||||
@@ -639,30 +609,21 @@ export function BlueprintFieldCatalog({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Label{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
Label <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customLabel}
|
||||
onChange={(e) =>
|
||||
setCustomLabel(e.target.value)
|
||||
}
|
||||
onChange={(e) => setCustomLabel(e.target.value)}
|
||||
placeholder="Display Label"
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Type
|
||||
</label>
|
||||
<label className="text-xs font-medium text-gray-600">Type</label>
|
||||
<select
|
||||
value={customType}
|
||||
onChange={(e) =>
|
||||
setCustomType(
|
||||
e.target.value as FieldType,
|
||||
)
|
||||
}
|
||||
onChange={(e) => setCustomType(e.target.value as FieldType)}
|
||||
className="app-input"
|
||||
>
|
||||
{FIELD_TYPES.map((ft) => (
|
||||
@@ -677,9 +638,7 @@ export function BlueprintFieldCatalog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomField}
|
||||
disabled={
|
||||
!customKey.trim() || !customLabel.trim()
|
||||
}
|
||||
disabled={!customKey.trim() || !customLabel.trim()}
|
||||
className={BTN_PRIMARY}
|
||||
>
|
||||
Add
|
||||
@@ -704,8 +663,7 @@ export function BlueprintFieldCatalog({
|
||||
onClick={() => setShowCustomForm(true)}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium py-2"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span>{" "}
|
||||
Add Custom Field
|
||||
<span className="text-lg leading-none">+</span> Add Custom Field
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -726,8 +684,7 @@ export function BlueprintFieldCatalog({
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<span className="text-xs text-gray-400">
|
||||
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be
|
||||
saved
|
||||
{enabledCount} field{enabledCount !== 1 ? "s" : ""} will be saved
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||
@@ -747,8 +704,8 @@ export function BlueprintFieldCatalog({
|
||||
) : (
|
||||
<div className="px-6 py-4 overflow-y-auto">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation
|
||||
Wizard when this blueprint is selected.
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
||||
blueprint is selected.
|
||||
</p>
|
||||
<RolePresetsEditor
|
||||
initialPresets={initialRolePresets}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FieldType } from "@capakraken/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@capakraken/shared";
|
||||
import { FieldType } from "@nexus/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||
|
||||
@@ -53,9 +53,7 @@ function OptionsEditor({ options, onChange }: OptionsEditorProps) {
|
||||
}
|
||||
|
||||
function updateOption(idx: number, field: "value" | "label", val: string) {
|
||||
const next = options.map((o, i) =>
|
||||
i === idx ? { ...o, [field]: val } : o,
|
||||
);
|
||||
const next = options.map((o, i) => (i === idx ? { ...o, [field]: val } : o));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
@@ -111,8 +109,7 @@ interface FieldRowProps {
|
||||
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const needsOptions =
|
||||
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||
const needsOptions = field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||
|
||||
function update<K extends keyof BlueprintFieldDefinition>(
|
||||
key: K,
|
||||
@@ -126,9 +123,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
{/* Main row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Drag handle placeholder */}
|
||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">☰</span>
|
||||
|
||||
{/* Key */}
|
||||
<input
|
||||
@@ -158,7 +153,7 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
// Clear options when switching away from select types
|
||||
const clearedOptions =
|
||||
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
||||
? field.options ?? []
|
||||
? (field.options ?? [])
|
||||
: undefined;
|
||||
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
||||
}}
|
||||
@@ -218,29 +213,21 @@ function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Placeholder
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 font-medium">Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
update("placeholder", e.target.value || undefined)
|
||||
}
|
||||
onChange={(e) => update("placeholder", e.target.value || undefined)}
|
||||
placeholder="Placeholder text"
|
||||
className="app-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Description
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 font-medium">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.description ?? ""}
|
||||
onChange={(e) =>
|
||||
update("description", e.target.value || undefined)
|
||||
}
|
||||
onChange={(e) => update("description", e.target.value || undefined)}
|
||||
placeholder="Helper text"
|
||||
className="app-input"
|
||||
/>
|
||||
@@ -311,9 +298,8 @@ export function BlueprintFieldEditor({
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
|
||||
() =>
|
||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(() =>
|
||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
||||
@@ -327,17 +313,11 @@ export function BlueprintFieldEditor({
|
||||
}
|
||||
|
||||
function removeField(idx: number) {
|
||||
setFields((prev) =>
|
||||
prev
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((f, i) => ({ ...f, order: i })),
|
||||
);
|
||||
setFields((prev) => prev.filter((_, i) => i !== idx).map((f, i) => ({ ...f, order: i })));
|
||||
}
|
||||
|
||||
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
||||
setFields((prev) =>
|
||||
prev.map((f, i) => (i === idx ? updated : f)),
|
||||
);
|
||||
setFields((prev) => prev.map((f, i) => (i === idx ? updated : f)));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
@@ -375,8 +355,7 @@ export function BlueprintFieldEditor({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Edit Fields:{" "}
|
||||
<span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||
Edit Fields: <span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -461,7 +440,8 @@ export function BlueprintFieldEditor({
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this
|
||||
blueprint is selected.
|
||||
</p>
|
||||
<RolePresetsEditor
|
||||
initialPresets={initialRolePresets}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import type { BlueprintTarget } from "@capakraken/shared";
|
||||
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
||||
import type { BlueprintTarget } from "@nexus/shared";
|
||||
import type { BlueprintFieldDefinition } from "@nexus/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BlueprintFieldCatalog } from "./BlueprintFieldCatalog.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
@@ -637,7 +637,7 @@ export function BlueprintsClient() {
|
||||
}
|
||||
initialRolePresets={
|
||||
Array.isArray(editingBlueprint.rolePresets)
|
||||
? (editingBlueprint.rolePresets as import("@capakraken/shared").StaffingRequirement[])
|
||||
? (editingBlueprint.rolePresets as import("@nexus/shared").StaffingRequirement[])
|
||||
: []
|
||||
}
|
||||
initialTab={editingTab}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user