diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d104ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Dependencies (reinstalled in container) +node_modules +**/node_modules + +# Build outputs +.next +**/dist +**/.turbo + +# Git +.git +.gitignore + +# Dev tooling +.vscode +.idea +*.swp +*.swo + +# Environment files (injected at runtime) +.env +.env.* +!.env.example + +# Test artifacts +coverage +**/coverage +e2e-results +playwright-report + +# Docker files (avoid recursive context) +Dockerfile* +docker-compose* + +# Documentation +docs +*.md +!packages/*/README.md + +# OS files +.DS_Store +Thumbs.db diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7e3ba3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,33 @@ +version: 2 + +updates: + # npm dependencies (pnpm monorepo — root handles workspaces) + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - minor + - patch + labels: + - dependencies + + # GitHub Actions + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions-minor-patch: + update-types: + - minor + - patch + labels: + - dependencies + - ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0c644c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,271 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: "20" + PNPM_VERSION: "9.14.2" + +jobs: + # ────────────────────────────────────────────── + # Typecheck — ~40s, no services needed + # ────────────────────────────────────────────── + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @planarchy/db exec prisma generate + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-typecheck-${{ github.sha }} + restore-keys: turbo-typecheck- + + - name: Run typecheck + run: pnpm --filter @planarchy/web exec tsc --noEmit + + # ────────────────────────────────────────────── + # Lint — ~20s, no services needed + # ────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @planarchy/db exec prisma generate + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-lint-${{ github.sha }} + restore-keys: turbo-lint- + + - name: Run lint + run: pnpm lint + + # ────────────────────────────────────────────── + # Unit tests — needs PostgreSQL + Redis + # ────────────────────────────────────────────── + test: + name: Unit Tests + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: planarchy_test + POSTGRES_USER: planarchy + POSTGRES_PASSWORD: planarchy_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U planarchy -d planarchy_test" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://planarchy:planarchy_test@localhost:5432/planarchy_test + REDIS_URL: redis://localhost:6379 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @planarchy/db exec prisma generate + + - name: Run unit tests with coverage + run: | + pnpm --filter @planarchy/engine exec vitest run --coverage + pnpm --filter @planarchy/staffing exec vitest run --coverage + pnpm --filter @planarchy/api exec vitest run --coverage + pnpm --filter @planarchy/application exec vitest run --coverage + pnpm --filter @planarchy/shared exec vitest run --coverage + pnpm --filter @planarchy/db test:unit + + # ────────────────────────────────────────────── + # Build — depends on typecheck passing + # ────────────────────────────────────────────── + build: + name: Build + needs: [typecheck] + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://placeholder:placeholder@localhost:5432/placeholder + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @planarchy/db exec prisma generate + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-build-${{ github.sha }} + restore-keys: turbo-build- + + - name: Cache Next.js build + uses: actions/cache@v4 + with: + path: apps/web/.next/cache + key: nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: nextjs-${{ hashFiles('pnpm-lock.yaml') }}- + + - name: Build + run: pnpm --filter @planarchy/web exec next build + + # ────────────────────────────────────────────── + # E2E — depends on build, needs PostgreSQL + Redis + # ────────────────────────────────────────────── + e2e: + name: E2E Tests + needs: [build] + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: planarchy_test + POSTGRES_USER: planarchy + POSTGRES_PASSWORD: planarchy_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U planarchy -d planarchy_test" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + env: + DATABASE_URL: postgresql://planarchy:planarchy_test@localhost:5432/planarchy_test + REDIS_URL: redis://localhost:6379 + PORT: 3100 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @planarchy/db exec prisma generate + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('apps/web/package.json') }} + restore-keys: playwright- + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm --filter @planarchy/web exec playwright install --with-deps chromium + + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm --filter @planarchy/web exec playwright install-deps chromium + + - name: Push DB schema & seed + run: | + pnpm db:push + pnpm db:seed + + - name: Run E2E tests + run: pnpm test:e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: apps/web/playwright-report/ + retention-days: 14 diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..4894f3d --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,80 @@ +# ============================================================ +# Stage 1: Install dependencies +# ============================================================ +FROM node:20-bookworm-slim AS deps + +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +RUN npm install -g pnpm@9.14.2 + +WORKDIR /app + +# Copy workspace manifests first for better layer caching +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY tooling/ ./tooling/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/db/package.json ./packages/db/ +COPY packages/engine/package.json ./packages/engine/ +COPY packages/staffing/package.json ./packages/staffing/ +COPY packages/application/package.json ./packages/application/ +COPY packages/api/package.json ./packages/api/ +COPY packages/ui/package.json ./packages/ui/ +COPY apps/web/package.json ./apps/web/ + +RUN pnpm install --frozen-lockfile + +# ============================================================ +# Stage 2: Build the application +# ============================================================ +FROM node:20-bookworm-slim AS builder + +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* +RUN npm install -g pnpm@9.14.2 + +WORKDIR /app + +# Copy installed dependencies from stage 1 +COPY --from=deps /app/ ./ + +# Copy all source code +COPY . . + +# Generate Prisma client +RUN pnpm --filter @planarchy/db db:generate + +# Build the Next.js application +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production +RUN pnpm --filter @planarchy/web build + +# ============================================================ +# Stage 3: Production runtime +# ============================================================ +FROM node:20-bookworm-slim AS runner + +RUN apt-get update -y && apt-get install -y openssl curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy the standalone output (includes server.js and node_modules) +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ + +# Copy static assets and public files +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public + +USER nextjs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts new file mode 100644 index 0000000..5f15fd1 --- /dev/null +++ b/apps/web/e2e/admin.spec.ts @@ -0,0 +1,52 @@ +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@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + }); + + 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 }); + }); + + 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 }); + // Should show a table with at least the admin user + await expect(page.locator("table")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=admin@planarchy.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 }); + // Should show table or list of roles + 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 }); + // Should show blueprint cards or list from seed data + await expect( + page.locator("table") + .or(page.locator("text=3D Content Production")) + .or(page.locator("text=No blueprints")), + ).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/apps/web/e2e/allocations.spec.ts b/apps/web/e2e/allocations.spec.ts new file mode 100644 index 0000000..de5320b --- /dev/null +++ b/apps/web/e2e/allocations.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Allocations", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/allocations"); + }); + + test("allocation list loads with table", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // The page title should be visible + await expect( + page.locator("h1").filter({ hasText: /Allocations|Planning/i }), + ).toBeVisible({ timeout: 10000 }); + // Table or empty state should be present + await expect( + page.locator("table").or(page.locator("text=No allocations")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("new planning entry modal opens", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const newBtn = page.locator("button", { hasText: /New Planning Entry/i }); + await expect(newBtn).toBeVisible({ timeout: 10000 }); + await newBtn.click(); + // Modal should appear with form fields + await expect( + page.locator("[role='dialog']").or(page.locator("text=Create").or(page.locator("text=Project"))), + ).toBeVisible({ timeout: 5000 }); + 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(); + 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(); + } + }); + + test("column toggle panel works", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const colToggle = page.locator("button", { hasText: /Columns/i }); + if ((await colToggle.count()) > 0) { + 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 page.keyboard.press("Escape"); + } + }); +}); diff --git a/apps/web/e2e/dashboard.spec.ts b/apps/web/e2e/dashboard.spec.ts new file mode 100644 index 0000000..798b5b8 --- /dev/null +++ b/apps/web/e2e/dashboard.spec.ts @@ -0,0 +1,46 @@ +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@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/dashboard"); + }); + + test("loads with widget grid", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Dashboard should render the react-grid-layout container + await expect(page.locator(".react-grid-layout")).toBeVisible({ timeout: 10000 }); + }); + + test("shows at least one widget", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Each widget is wrapped in a WidgetContainer inside the grid + const widgets = page.locator(".react-grid-item"); + await expect(widgets.first()).toBeVisible({ timeout: 10000 }); + const count = await widgets.count(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test("add widget modal opens and closes", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Look for the "Add Widget" button + 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 page.keyboard.press("Escape"); + } + }); + + test("reset layout button is available", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const resetBtn = page.locator("button", { hasText: /Reset Layout/i }); + if ((await resetBtn.count()) > 0) { + await expect(resetBtn).toBeVisible(); + } + }); +}); diff --git a/apps/web/e2e/estimates.spec.ts b/apps/web/e2e/estimates.spec.ts new file mode 100644 index 0000000..b1c4e58 --- /dev/null +++ b/apps/web/e2e/estimates.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Estimates", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/estimates"); + }); + + test("estimate list loads", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect( + page.locator("h1").filter({ hasText: /Estimates/i }), + ).toBeVisible({ timeout: 10000 }); + // Should show either a table/list or an empty state + await expect( + page.locator("table").or(page.locator("text=No estimates")).or(page.locator("[data-estimate-id]")), + ).toBeVisible({ timeout: 10000 }); + }); + + test("new estimate wizard opens with setup step", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const newBtn = page.locator("button", { hasText: /New Estimate/i }); + await expect(newBtn).toBeVisible({ timeout: 10000 }); + await newBtn.click(); + // Wizard step 1 should appear: "Setup" + await expect( + page.locator("text=Setup").or(page.locator("text=Estimate Name")), + ).toBeVisible({ timeout: 5000 }); + }); + + test("estimate wizard navigates through steps", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.locator("button", { hasText: /New Estimate/i }).click(); + + // Step 1: Setup — fill a name + await expect(page.locator("text=Setup")).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()}`); + } + + // Click Next to go to step 2 + const nextBtn = page.locator("button", { hasText: "Next" }); + if ((await nextBtn.count()) > 0) { + await nextBtn.click(); + await page.waitForTimeout(500); + // Step 2: Assumptions + await expect( + page.locator("text=Assumptions").or(page.locator("text=Scope")), + ).toBeVisible({ timeout: 5000 }); + } + + // Close the wizard without completing + const cancelBtn = page.locator("button", { hasText: /Cancel|Close/i }).first(); + if ((await cancelBtn.count()) > 0) { + await cancelBtn.click(); + } else { + await page.keyboard.press("Escape"); + } + }); + + test("estimate wizard shows all step labels", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.locator("button", { hasText: /New Estimate/i }).click(); + + // The wizard header should show step labels + const steps = ["Setup", "Assumptions", "Scope", "Staffing", "Review"]; + for (const step of steps) { + await expect(page.locator(`text=${step}`).first()).toBeVisible({ timeout: 5000 }); + } + + await page.keyboard.press("Escape"); + }); +}); diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts new file mode 100644 index 0000000..1db8faf --- /dev/null +++ b/apps/web/e2e/navigation.spec.ts @@ -0,0 +1,99 @@ +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@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + }); + + test("main nav links navigate correctly", async ({ page }) => { + const navRoutes = [ + { label: "Dashboard", url: "/dashboard" }, + { label: "Timeline", url: "/timeline" }, + { label: "Allocations", url: "/allocations" }, + { label: "Resources", url: "/resources" }, + { label: "Projects", url: "/projects" }, + ]; + + for (const route of navRoutes) { + const navLink = page.locator(`nav a >> text="${route.label}"`).first(); + await navLink.click(); + await page.waitForLoadState("networkidle"); + await expect(page).toHaveURL(new RegExp(route.url)); + } + }); + + test("sidebar collapse and expand works", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + // Find the collapse button (contains "Collapse" text) + const collapseBtn = page.locator("nav button", { hasText: "Collapse" }); + if ((await collapseBtn.count()) > 0) { + await collapseBtn.click(); + await page.waitForTimeout(300); + // After collapsing, the sidebar should be narrow (72px) + const nav = page.locator("nav").first(); + const box = await nav.boundingBox(); + if (box) { + expect(box.width).toBeLessThan(100); + } + + // Expand again — the button should still be visible as an icon + const expandBtn = page.locator("nav button").filter({ has: page.locator("svg") }).last(); + await expandBtn.click(); + await page.waitForTimeout(300); + const boxExpanded = await nav.boundingBox(); + if (boxExpanded) { + expect(boxExpanded.width).toBeGreaterThan(200); + } + } + }); + + test("preferences modal opens via sidebar button", async ({ page }) => { + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + const prefsBtn = page.locator("nav button", { hasText: "Preferences" }); + if ((await prefsBtn.count()) > 0) { + await prefsBtn.click(); + await expect(page.locator("text=Preferences")).toBeVisible({ timeout: 5000 }); + // Should show theme/mode controls + await expect( + page.locator("text=Light").or(page.locator("text=Dark").or(page.locator("text=System"))), + ).toBeVisible(); + // Close the modal + await page.keyboard.press("Escape"); + } + }); + + test("mobile hamburger menu opens sidebar on small viewport", async ({ page }) => { + // Set a mobile viewport size + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/dashboard"); + await page.waitForLoadState("networkidle"); + + // The hamburger button should be visible on mobile + const hamburgerBtn = page.locator("button").filter({ has: page.locator("svg") }).first(); + await expect(hamburgerBtn).toBeVisible({ timeout: 5000 }); + + await hamburgerBtn.click(); + await page.waitForTimeout(300); + + // Mobile sidebar overlay should appear with nav links + await expect(page.locator("text=Dashboard")).toBeVisible(); + await expect(page.locator("text=Timeline")).toBeVisible(); + + // Close button should be visible in mobile sidebar + const closeBtn = page + .locator("nav button") + .filter({ has: page.locator("svg") }) + .first(); + if ((await closeBtn.count()) > 0) { + await closeBtn.click(); + } + }); +}); diff --git a/apps/web/e2e/staffing.spec.ts b/apps/web/e2e/staffing.spec.ts new file mode 100644 index 0000000..d38049c --- /dev/null +++ b/apps/web/e2e/staffing.spec.ts @@ -0,0 +1,42 @@ +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@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/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 }); + // Search form should have skill input, date fields, and a search button + await expect(page.locator("text=How scoring works")).toBeVisible({ timeout: 10000 }); + }); + + 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 }); + }); + + test("submitting search returns suggestions or empty state", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Click the search/submit button + const searchBtn = page.locator("button", { hasText: /Search|Find|Suggest/i }).first(); + await expect(searchBtn).toBeVisible({ timeout: 10000 }); + await searchBtn.click(); + 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() + .or(page.locator("[data-suggestion]").first()) + .or(page.locator("table").first()), + ).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/apps/web/e2e/vacations.spec.ts b/apps/web/e2e/vacations.spec.ts new file mode 100644 index 0000000..b5e7bc9 --- /dev/null +++ b/apps/web/e2e/vacations.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Vacations", () => { + test.describe("My Vacations (self-service)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/vacations/my"); + }); + + test("my vacations page loads", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1", { hasText: "My Vacations" })).toBeVisible({ timeout: 10000 }); + }); + + test("request vacation button is visible", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await expect( + page.locator("button", { hasText: /Request Vacation/i }), + ).toBeVisible({ timeout: 10000 }); + }); + + test("request vacation modal opens", async ({ page }) => { + await page.waitForLoadState("networkidle"); + const reqBtn = page.locator("button", { hasText: /Request Vacation/i }); + await reqBtn.click(); + // Modal should show vacation form + await expect( + page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")), + ).toBeVisible({ timeout: 5000 }); + await page.keyboard.press("Escape"); + }); + }); + + test.describe("Vacation Management", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@planarchy.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); + await page.goto("/vacations"); + }); + + test("vacation management page loads with tabs", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Should show List and Team Calendar tabs + await expect(page.locator("text=List").first()).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Team Calendar").first()).toBeVisible({ timeout: 10000 }); + }); + + 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.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"))), + ).toBeVisible({ timeout: 10000 }); + }); + + test("filter chips are visible on list tab", async ({ page }) => { + await page.waitForLoadState("networkidle"); + // Status filter options should be visible + await expect( + page.locator("button", { hasText: /All|Pending|Approved/i }).first(), + ).toBeVisible({ timeout: 10000 }); + }); + }); +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 49e369a..8ee077a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,12 @@ import path from "path"; import type { NextConfig } from "next"; +import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { + output: "standalone", + experimental: { + optimizePackageImports: ["recharts", "date-fns"], + }, transpilePackages: [ "@planarchy/api", "@planarchy/db", @@ -40,4 +45,10 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + silent: true, + sourcemaps: { + disable: true, + }, + telemetry: false, +}); diff --git a/apps/web/package.json b/apps/web/package.json index 01f61df..6d38115 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,12 +19,14 @@ "@planarchy/shared": "workspace:*", "@planarchy/ui": "workspace:*", "@react-pdf/renderer": "^4.3.2", + "@sentry/nextjs": "^10.45.0", "@tanstack/react-query": "^5.62.16", "@tanstack/react-virtual": "^3.13.21", "@trpc/client": "^11.0.0", "@trpc/react-query": "^11.0.0", "@trpc/server": "^11.0.0", "clsx": "^2.1.1", + "framer-motion": "^12.38.0", "next": "^15.1.7", "next-auth": "^5.0.0-beta.25", "react": "^19.0.0", diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 0000000..a3ae1e1 Binary files /dev/null and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 0000000..4d234ff Binary files /dev/null and b/apps/web/public/icon-512.png differ diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 0000000..858c56e --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Planarchy — Resource Planning", + "short_name": "Planarchy", + "description": "Resource planning and project staffing for 3D production", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#0284c7", + "orientation": "any", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/apps/web/public/og-image.png b/apps/web/public/og-image.png new file mode 100644 index 0000000..0ff5257 Binary files /dev/null and b/apps/web/public/og-image.png differ diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..e52a98b --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,128 @@ +/// + +const CACHE_NAME = "planarchy-v1"; +const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/; + +// Offline fallback page (simple inline HTML) +const OFFLINE_HTML = ` + + + + + Planarchy — Offline + + + +
+

You are offline

+

Planarchy requires an internet connection. Please check your network and try again.

+ +
+ +`; + +// Install: pre-cache the offline fallback +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.put( + new Request("/_offline"), + new Response(OFFLINE_HTML, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }) + ); + }) + ); + // Activate immediately + self.skipWaiting(); +}); + +// Activate: clean up old caches +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + ); + // Take control of all clients immediately + self.clients.claim(); +}); + +// Fetch: strategy depends on request type +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== "GET") return; + + // Skip chrome-extension, ws, etc. + if (!url.protocol.startsWith("http")) return; + + // API calls and tRPC: network-first + if (url.pathname.startsWith("/api/")) { + event.respondWith( + fetch(request).catch(() => { + return new Response( + JSON.stringify({ error: "offline" }), + { + status: 503, + headers: { "Content-Type": "application/json" }, + } + ); + }) + ); + return; + } + + // Static assets: cache-first + if (STATIC_EXTENSIONS.test(url.pathname) || url.pathname.startsWith("/_next/static/")) { + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + // Only cache successful responses + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }); + }) + ); + return; + } + + // Navigation requests: network-first with offline fallback + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => caches.match("/_offline")) + ); + return; + } + + // Everything else: network-first, silent fail + event.respondWith( + fetch(request).catch(() => caches.match(request)) + ); +}); diff --git a/apps/web/sentry.client.config.ts b/apps/web/sentry.client.config.ts new file mode 100644 index 0000000..ad82954 --- /dev/null +++ b/apps/web/sentry.client.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 0.1, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 1.0, + enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, +}); diff --git a/apps/web/sentry.edge.config.ts b/apps/web/sentry.edge.config.ts new file mode 100644 index 0000000..ef064cb --- /dev/null +++ b/apps/web/sentry.edge.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 0.1, + enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, +}); diff --git a/apps/web/sentry.server.config.ts b/apps/web/sentry.server.config.ts new file mode 100644 index 0000000..ef064cb --- /dev/null +++ b/apps/web/sentry.server.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + tracesSampleRate: 0.1, + enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, +}); diff --git a/apps/web/src/app/(app)/admin/notifications/page.tsx b/apps/web/src/app/(app)/admin/notifications/page.tsx new file mode 100644 index 0000000..b17ec22 --- /dev/null +++ b/apps/web/src/app/(app)/admin/notifications/page.tsx @@ -0,0 +1,5 @@ +import { BroadcastManagementClient } from "~/components/notifications/BroadcastManagementClient.js"; + +export default function AdminNotificationsPage() { + return ; +} diff --git a/apps/web/src/app/(app)/admin/system-roles/page.tsx b/apps/web/src/app/(app)/admin/system-roles/page.tsx new file mode 100644 index 0000000..913370f --- /dev/null +++ b/apps/web/src/app/(app)/admin/system-roles/page.tsx @@ -0,0 +1,21 @@ +import dynamic from "next/dynamic"; + +const SystemRolesClient = dynamic( + () => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient), + { + loading: () => ( +
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ ), + }, +); + +export default function SystemRolesPage() { + return ; +} diff --git a/apps/web/src/app/(app)/admin/vacations/page.tsx b/apps/web/src/app/(app)/admin/vacations/page.tsx index 8037834..fdf5a96 100644 --- a/apps/web/src/app/(app)/admin/vacations/page.tsx +++ b/apps/web/src/app/(app)/admin/vacations/page.tsx @@ -1,7 +1,7 @@ import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js"; import { EntitlementManager } from "~/components/vacations/EntitlementManager.js"; -export const metadata = { title: "Vacation Management — Planarchy" }; +export const metadata = { title: "Vacation Management — plANARCHY" }; export default function AdminVacationsPage() { return ( diff --git a/apps/web/src/app/(app)/admin/webhooks/page.tsx b/apps/web/src/app/(app)/admin/webhooks/page.tsx new file mode 100644 index 0000000..48961c1 --- /dev/null +++ b/apps/web/src/app/(app)/admin/webhooks/page.tsx @@ -0,0 +1,5 @@ +import { WebhooksClient } from "~/components/admin/WebhooksClient.js"; + +export default function AdminWebhooksPage() { + return ; +} diff --git a/apps/web/src/app/(app)/allocations/loading.tsx b/apps/web/src/app/(app)/allocations/loading.tsx index 320fd49..102fbb5 100644 --- a/apps/web/src/app/(app)/allocations/loading.tsx +++ b/apps/web/src/app/(app)/allocations/loading.tsx @@ -1,43 +1,43 @@ export default function AllocationsLoading() { return ( -
+
{/* Header */}
-
-
+
+
{/* Filter bar */}
-
-
-
+
+
+
{/* Table */}
{/* Header */}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
{/* Rows */} {[...Array(10)].map((_, i) => ( -
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
))}
diff --git a/apps/web/src/app/(app)/allocations/page.tsx b/apps/web/src/app/(app)/allocations/page.tsx index 54fd548..d2cd713 100644 --- a/apps/web/src/app/(app)/allocations/page.tsx +++ b/apps/web/src/app/(app)/allocations/page.tsx @@ -1,4 +1,21 @@ -import { AllocationsClient } from "~/components/allocations/AllocationsClient.js"; +import dynamic from "next/dynamic"; + +const AllocationsClient = dynamic( + () => import("~/components/allocations/AllocationsClient.js").then((m) => m.AllocationsClient), + { + loading: () => ( +
+
+
+
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+
+ ), + }, +); export default function AllocationsPage() { return ; diff --git a/apps/web/src/app/(app)/analytics/computation-graph/page.tsx b/apps/web/src/app/(app)/analytics/computation-graph/page.tsx new file mode 100644 index 0000000..8938d91 --- /dev/null +++ b/apps/web/src/app/(app)/analytics/computation-graph/page.tsx @@ -0,0 +1,5 @@ +import ComputationGraphClient from "~/components/analytics/ComputationGraphClient"; + +export default function ComputationGraphPage() { + return ; +} diff --git a/apps/web/src/app/(app)/analytics/insights/page.tsx b/apps/web/src/app/(app)/analytics/insights/page.tsx new file mode 100644 index 0000000..afa268b --- /dev/null +++ b/apps/web/src/app/(app)/analytics/insights/page.tsx @@ -0,0 +1,17 @@ +import { InsightsPanel } from "~/components/analytics/InsightsPanel.js"; + +export default function InsightsPage() { + return ( +
+
+

+ AI Insights +

+

+ Anomaly detection and AI-generated project narratives +

+
+ +
+ ); +} diff --git a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx new file mode 100644 index 0000000..57f5ef0 --- /dev/null +++ b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx @@ -0,0 +1,5 @@ +import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js"; + +export default function SkillMarketplacePage() { + return ; +} diff --git a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx index a145fc1..d92e1ac 100644 --- a/apps/web/src/app/(app)/estimates/EstimatesClient.tsx +++ b/apps/web/src/app/(app)/estimates/EstimatesClient.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared"; import { clsx } from "clsx"; import { EstimateWizard } from "~/components/estimates/EstimateWizard.js"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { formatDateLong, formatMoney } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -69,18 +70,18 @@ type EstimateDetail = { }; const STATUS_STYLES: Record = { - DRAFT: "bg-slate-100 text-slate-700", - IN_REVIEW: "bg-amber-100 text-amber-700", - APPROVED: "bg-emerald-100 text-emerald-700", - ARCHIVED: "bg-zinc-200 text-zinc-700", + DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300", + IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300", }; const VERSION_STYLES: Record = { - WORKING: "bg-sky-100 text-sky-700", - BASELINE: "bg-violet-100 text-violet-700", - SUBMITTED: "bg-amber-100 text-amber-700", - APPROVED: "bg-emerald-100 text-emerald-700", - SUPERSEDED: "bg-zinc-200 text-zinc-700", + WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300", + BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300", + SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", + APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", + SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300", }; function formatMetricValue(metric: EstimateMetric) { @@ -121,7 +122,7 @@ function EstimateDetailPanel({

- Estimate detail + Estimate detail

{estimate.name} @@ -145,7 +146,7 @@ function EstimateDetailPanel({
Open workspace @@ -164,7 +165,7 @@ function EstimateDetailPanel({ {latestVersion ? ( <>
- + Version {latestVersion.versionNumber} {latestVersion.label ? ` - ${latestVersion.label}` : ""} @@ -205,13 +206,13 @@ function EstimateDetailPanel({

- Scope items + Scope items

{latestVersion.scopeItems.length}
{latestVersion.scopeItems.length === 0 ? ( -

+

No scope rows captured yet.

) : ( @@ -238,13 +239,13 @@ function EstimateDetailPanel({

- Demand lines + Demand lines

{latestVersion.demandLines.length}
{latestVersion.demandLines.length === 0 ? ( -

+

No staffing demand captured yet.

) : ( @@ -272,7 +273,7 @@ function EstimateDetailPanel({
) : ( -

+

No versions available for this estimate yet.

)} @@ -301,8 +302,8 @@ function EstimateCard({ className={clsx( "w-full rounded-3xl border p-5 text-left transition", active - ? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30" - : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700", + ? "border-brand-500 bg-brand-50 shadow-sm dark:border-sky-400 dark:bg-sky-950/30" + : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600", !canInspect && "cursor-default", )} > @@ -318,7 +319,7 @@ function EstimateCard({ {estimate.status.replace("_", " ")} {estimate.project && ( - + {estimate.project.shortCode} )} @@ -344,13 +345,13 @@ function EstimateCard({
-

Opportunity

+

Opportunity

{estimate.opportunityId ?? "Not set"}

-

Updated

+

Updated

{formatDateLong(estimate.updatedAt)}

@@ -407,7 +408,7 @@ export function EstimatesClient() { return ( <>
-
+

@@ -465,7 +466,7 @@ export function EstimatesClient() { No estimates yet

- Start with the wizard to create a connected estimate from Planarchy data. + Start with the wizard to create a connected estimate from plANARCHY data.

) : ( diff --git a/apps/web/src/app/(app)/loading.tsx b/apps/web/src/app/(app)/loading.tsx index 1e9d8df..9e332c7 100644 --- a/apps/web/src/app/(app)/loading.tsx +++ b/apps/web/src/app/(app)/loading.tsx @@ -1,15 +1,15 @@ export default function AppLoading() { return ( -
-
-
+
+
+
{[0, 1, 2, 3].map((i) => ( -
+
))}
-
-
+
+
); } diff --git a/apps/web/src/app/(app)/notifications/page.tsx b/apps/web/src/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..ef11f83 --- /dev/null +++ b/apps/web/src/app/(app)/notifications/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { NotificationCenterClient } from "~/components/notifications/NotificationCenterClient.js"; + +export default function NotificationsPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 8b650fe..6f99a20 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -2,12 +2,15 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; -import { formatDate } from "~/lib/format.js"; +import { formatDate, formatMoney } from "~/lib/format.js"; import type { Project, ColumnDef } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; import Link from "next/link"; +import Image from "next/image"; import { clsx } from "clsx"; +import { motion } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; +import { generateCsv, downloadCsv } from "~/lib/csv-export.js"; import { ProjectModal } from "~/components/projects/ProjectModal.js"; import { ProjectWizard } from "~/components/projects/ProjectWizard.js"; import { useSelection } from "~/hooks/useSelection.js"; @@ -59,7 +62,7 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu return (
-
+
{utilizationPercent.toFixed(0)}% used
@@ -116,9 +119,12 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project {isOpen && createPortal( -
{ALL_STATUSES.map((s) => ( @@ -139,7 +145,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project ))} -
, + , document.body, )} @@ -162,6 +168,8 @@ interface ProjectRow { totalPersonDays: number; utilizationPercent: number; dynamicFields?: Record | null; + coverImageUrl?: string | null; + color?: string | null; } // ─── Main component ─────────────────────────────────────────────────────────── @@ -176,6 +184,7 @@ export function ProjectsClient() { const [openStatusProjectId, setOpenStatusProjectId] = useState(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); + const [confirmBatchDelete, setConfirmBatchDelete] = useState(null); const selection = useSelection(); const utils = trpc.useUtils(); @@ -188,6 +197,13 @@ export function ProjectsClient() { }, }); + const batchDeleteMutation = trpc.project.batchDelete.useMutation({ + onSuccess: async () => { + await utils.project.listWithCosts.invalidate(); + selection.clear(); + }, + }); + // ─── Favorites ────────────────────────────────────────────────────────── const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 }); const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]); @@ -329,6 +345,25 @@ export function ProjectsClient() { function closeModal() { setModalOpen(false); setEditingProject(null); } function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); } + const exportSelectedCsv = useCallback(() => { + const selected = projects.filter((p) => selection.selectedIds.has(p.id)); + if (selected.length === 0) return; + const csv = generateCsv(selected, [ + { header: "Short Code", accessor: (p) => p.shortCode }, + { header: "Name", accessor: (p) => p.name }, + { header: "Status", accessor: (p) => p.status }, + { header: "Order Type", accessor: (p) => p.orderType }, + { header: "Start Date", accessor: (p) => formatDate(p.startDate) }, + { header: "End Date", accessor: (p) => formatDate(p.endDate) }, + { header: "Budget (cents)", accessor: (p) => p.budgetCents }, + { header: "Win Probability", accessor: (p) => p.winProbability }, + { header: "Total Cost (cents)", accessor: (p) => p.totalCostCents }, + { header: "Person Days", accessor: (p) => p.totalPersonDays }, + { header: "Utilization %", accessor: (p) => p.utilizationPercent }, + ]); + downloadCsv(csv, `projects-export-${new Date().toISOString().slice(0, 10)}.csv`); + }, [projects, selection.selectedIds]); + const chips = [ ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), @@ -351,8 +386,21 @@ export function ProjectsClient() { case "name": return ( - - {project.name} + + {project.coverImageUrl ? ( + {project.name} + ) : ( + + {project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()} + + )} + {project.name} ); @@ -385,15 +433,24 @@ export function ProjectsClient() { return (
- {(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} € + {formatMoney(project.budgetCents)}
); case "allocations": return ( - - {project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"} + + {project.totalPersonDays > 0 ? ( + + + + + {project.totalPersonDays}d + + ) : ( + + )} ); case "responsible": @@ -515,7 +572,7 @@ export function ProjectsClient() {
{isLoading ? ( -
Loading projects…
+
Loading projects…
) : ( <>
@@ -543,7 +600,7 @@ export function ProjectsClient() { - {projects.map((project) => { + {projects.map((project, index) => { const isSelected = selection.selectedIds.has(project.id); return ( reorder(draggedId, project.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > - + View →
@@ -652,12 +710,34 @@ export function ProjectsClient() { /> )} + {/* Confirm batch delete */} + {confirmBatchDelete && ( + { + batchDeleteMutation.mutate({ ids: confirmBatchDelete }); + setConfirmBatchDelete(null); + }} + onCancel={() => setConfirmBatchDelete(null)} + /> + )} + {/* Batch Action Bar */} setBatchStatusPicker(true) }, + { label: "Export Selected", onClick: exportSelectedCsv }, + { label: "Set Status...", onClick: () => setBatchStatusPicker(true) }, + { + label: `Delete (${selection.count})`, + variant: "danger" as const, + onClick: () => setConfirmBatchDelete(selection.selectedArray), + disabled: batchDeleteMutation.isPending, + }, ]} /> diff --git a/apps/web/src/app/(app)/projects/[id]/page.tsx b/apps/web/src/app/(app)/projects/[id]/page.tsx index 73db05c..38175f1 100644 --- a/apps/web/src/app/(app)/projects/[id]/page.tsx +++ b/apps/web/src/app/(app)/projects/[id]/page.tsx @@ -2,11 +2,16 @@ import { notFound } from "next/navigation"; import { formatDate } from "~/lib/format.js"; import Link from "next/link"; import { createCaller } from "~/server/trpc.js"; +import { auth } from "~/server/auth.js"; import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js"; import { ProjectDetailActions } from "~/components/projects/ProjectDetailClient.js"; import { ProjectDemandsTable } from "~/components/projects/ProjectDemandsTable.js"; import { ProjectAssignmentsTable } from "~/components/projects/ProjectAssignmentsTable.js"; import { PROJECT_STATUS_BADGE as STATUS_COLORS, ORDER_TYPE_BADGE as ORDER_TYPE_COLORS } from "~/lib/status-styles.js"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { CoverArtSection } from "~/components/projects/CoverArtSection.js"; + +const EDIT_ROLES = new Set(["ADMIN", "MANAGER"]); interface ProjectDetailPageProps { params: Promise<{ id: string }>; @@ -14,7 +19,9 @@ interface ProjectDetailPageProps { export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) { const { id } = await params; - const trpc = await createCaller(); + const [trpc, session] = await Promise.all([createCaller(), auth()]); + const userRole = (session?.user as { role?: string } | undefined)?.role ?? "USER"; + const canEditProject = EDIT_ROLES.has(userRole); let project: Awaited>; try { @@ -41,8 +48,18 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro Back to Projects + {/* Cover Art */} + + {/* Project header */} -
+
@@ -50,9 +67,11 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro {project.status} + {project.orderType} +

{project.name}

@@ -63,38 +82,48 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro {" — "} {formatDate(project.endDate)}
-
Win probability: {project.winProbability}%
+
Win probability: {project.winProbability}%
+ + + + + What-If +
-
Chargecode
+
Chargecode
{project.shortCode}
-
Order Type
+
Order Type
{project.orderType}
-
Allocation Type
+
Allocation Type
{project.allocationType}
-
Assignments
+
Assignments
{activeAssignments.length} active
-
Open Demands
+
Open Demands
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
{project.responsiblePerson && (
-
Responsible Person
+
Responsible Person
{project.responsiblePerson}
)} diff --git a/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx new file mode 100644 index 0000000..2bc0aeb --- /dev/null +++ b/apps/web/src/app/(app)/projects/[id]/scenario/page.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { createCaller } from "~/server/trpc.js"; +import { ScenarioPlanner } from "~/components/projects/ScenarioPlanner.js"; + +interface ScenarioPageProps { + params: Promise<{ id: string }>; +} + +export default async function ScenarioPage({ params }: ScenarioPageProps) { + const { id } = await params; + const trpc = await createCaller(); + + let baseline: Awaited>; + try { + baseline = await trpc.scenario.getProjectBaseline({ projectId: id }); + } catch { + notFound(); + } + + // Load resources and roles for the pickers + const [resources, roles] = await Promise.all([ + trpc.resource.list({ isActive: true }), + trpc.role.list({ isActive: true }), + ]); + + return ( +
+ + + + + Back to {baseline.project.name} + + +
+

+ What-If Scenario Planner +

+

+ Explore alternate staffing configurations for{" "} + {baseline.project.name}{" "} + and see instant cost/schedule impact. +

+
+ + +
+ ); +} diff --git a/apps/web/src/app/(app)/projects/loading.tsx b/apps/web/src/app/(app)/projects/loading.tsx index 95661b4..315f577 100644 --- a/apps/web/src/app/(app)/projects/loading.tsx +++ b/apps/web/src/app/(app)/projects/loading.tsx @@ -1,48 +1,48 @@ export default function ProjectsLoading() { return ( -
+
{/* Header */}
-
-
+
+
{/* Filter bar */}
-
-
-
+
+
+
{/* Table */}
{/* Header */}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{/* Rows */} {[...Array(10)].map((_, i) => ( -
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
-
+
+
+
))}
diff --git a/apps/web/src/app/(app)/reports/builder/page.tsx b/apps/web/src/app/(app)/reports/builder/page.tsx new file mode 100644 index 0000000..77ac0c7 --- /dev/null +++ b/apps/web/src/app/(app)/reports/builder/page.tsx @@ -0,0 +1,5 @@ +import { ReportBuilder } from "~/components/reports/ReportBuilder.js"; + +export default function ReportBuilderPage() { + return ; +} diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 438f859..6eea6bc 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -7,9 +7,16 @@ import type { Resource, SkillEntry } from "@planarchy/shared"; import { RESOURCE_COLUMNS } from "@planarchy/shared"; import { BlueprintTarget, ResourceType } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { formatMoney } from "~/lib/format.js"; +import { generateCsv, downloadCsv } from "~/lib/csv-export.js"; +import dynamic from "next/dynamic"; import { ResourceModal } from "~/components/resources/ResourceModal.js"; -import { ImportModal } from "~/components/resources/ImportModal.js"; import { BulkEditModal } from "~/components/resources/BulkEditModal.js"; + +const ImportModal = dynamic( + () => import("~/components/resources/ImportModal.js").then((mod) => mod.ImportModal), + { ssr: false }, +); import { useSelection } from "~/hooks/useSelection.js"; import { BatchActionBar } from "~/components/ui/BatchActionBar.js"; import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; @@ -502,6 +509,22 @@ export function ResourcesClient() { return "Departed: no"; }, [departedFilter]); + const exportSelectedCsv = useCallback(() => { + const selected = displayedResources.filter((r) => selection.selectedIds.has(r.id)); + if (selected.length === 0) return; + const csv = generateCsv(selected, [ + { header: "EID", accessor: (r) => r.eid }, + { header: "Name", accessor: (r) => r.displayName }, + { header: "Email", accessor: (r) => r.email }, + { header: "Chapter", accessor: (r) => r.chapter ?? "" }, + { header: "LCR (cents)", accessor: (r) => r.lcrCents }, + { header: "Currency", accessor: (r) => r.currency }, + { header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget }, + { header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" }, + ]); + downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`); + }, [displayedResources, selection.selectedIds]); + const chips = [ ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(chapterFilter.length > 0 @@ -936,7 +959,7 @@ export function ResourcesClient() { {/* Table */}
{isLoading && resources.length === 0 ? ( -
+
Loading resources…
) : ( @@ -978,7 +1001,7 @@ export function ResourcesClient() { sortField={sortField} sortDir={sortDir} onSort={toggle} - tooltip="Unique employee identifier used across all Planarchy records." + tooltip="Unique employee identifier used across all plANARCHY records." /> ); case "displayName": @@ -1096,7 +1119,7 @@ export function ResourcesClient() { - {displayedResources.map((resource) => { + {displayedResources.map((resource, index) => { const skills = resource.skills as unknown as SkillEntry[]; const isSelected = selection.selectedIds.has(resource.id); const isDeactivating = @@ -1113,7 +1136,8 @@ export function ResourcesClient() { id={resource.id} dragRef={rowDragRef} onDrop={(draggedId) => reorder(draggedId, resource.id)} - className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + className={`table-row-hover hover-lift hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} + style={{ animationDelay: `${Math.min(index * 15, 300)}ms` }} > ); - case "displayName": + case "displayName": { + const initials = resource.displayName + .split(/\s+/) + .map((w) => w[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase(); + const rr = + ( + resource as unknown as { + resourceRoles?: { + isPrimary: boolean; + role: { color: string | null }; + }[]; + } + ).resourceRoles ?? []; + const primaryRole = rr.find((r) => r.isPrimary); + const avatarColor = + primaryRole?.role.color ?? + `hsl(${[...resource.displayName].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360}, 55%, 45%)`; return ( - {resource.displayName} + + {initials} + + + + {resource.displayName} + + + {resource.email} + + -
- {resource.email} -
); + } case "chapter": return ( - {(resource.lcrCents / 100).toFixed(0)} {resource.currency} + {formatMoney(resource.lcrCents, resource.currency)} ); case "chargeability": { @@ -1200,8 +1255,20 @@ export function ResourcesClient() { : actual >= target - 20 ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300"; + // Bar color based on % of target achieved + const barRatio = actual != null && target > 0 ? actual / target : 0; + const barColor = + actual == null + ? "bg-gray-300 dark:bg-gray-600" + : barRatio >= 0.8 + ? "bg-green-500" + : barRatio >= 0.5 + ? "bg-amber-500" + : "bg-red-500"; + const barWidth = actual != null ? Math.min(actual, 100) : 0; + const isOverflow = actual != null && actual > 100; return ( - +
{actual != null ? `${actual}%` : "—"} @@ -1211,7 +1278,22 @@ export function ResourcesClient() { ({expected}% exp.) )} -
Target: {target}%
+ {actual !== target && ( +
Target: {target}%
+ )} + {actual != null && ( +
+
+
+
+ {isOverflow && ( + + + )} +
+ )}
); @@ -1296,7 +1378,7 @@ export function ResourcesClient() { {skills.slice(0, 3).map((s) => ( {s.skill} @@ -1326,7 +1408,7 @@ export function ResourcesClient() { onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource }) } - className="mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100" + className="link-hover-underline mr-3 text-xs font-medium text-brand-600 transition-colors hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100" > Edit @@ -1364,10 +1446,15 @@ export function ResourcesClient() { count={selection.count} onClear={selection.clear} actions={[ + { + label: "Export Selected", + variant: "default" as const, + onClick: exportSelectedCsv, + }, ...(filterableFields.length > 0 ? [ { - label: "Edit Custom Fields", + label: "Bulk Edit", variant: "default" as const, onClick: () => setModal({ type: "bulkEdit" }), disabled: false, diff --git a/apps/web/src/app/(app)/resources/[id]/page.tsx b/apps/web/src/app/(app)/resources/[id]/page.tsx index e96326b..7d0d6ec 100644 --- a/apps/web/src/app/(app)/resources/[id]/page.tsx +++ b/apps/web/src/app/(app)/resources/[id]/page.tsx @@ -9,9 +9,9 @@ export async function generateMetadata( try { const trpc = await createCaller(); const resource = await trpc.resource.getById({ id }); - return { title: `${resource.displayName} — Resources | Planarchy` }; + return { title: `${resource.displayName} — Resources | plANARCHY` }; } catch { - return { title: "Resource — Planarchy" }; + return { title: "Resource — plANARCHY" }; } } diff --git a/apps/web/src/app/(app)/resources/loading.tsx b/apps/web/src/app/(app)/resources/loading.tsx index 4a22692..269ac33 100644 --- a/apps/web/src/app/(app)/resources/loading.tsx +++ b/apps/web/src/app/(app)/resources/loading.tsx @@ -1,49 +1,49 @@ export default function ResourcesLoading() { return ( -
+
{/* Page header */}
-
-
+
+
{/* Filter bar */}
-
-
-
-
+
+
+
+
{/* Table */}
{/* Header */}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{/* Rows */} {[...Array(10)].map((_, i) => ( -
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
-
+
))}
diff --git a/apps/web/src/app/(app)/resources/page.tsx b/apps/web/src/app/(app)/resources/page.tsx index fbde078..cbec4bd 100644 --- a/apps/web/src/app/(app)/resources/page.tsx +++ b/apps/web/src/app/(app)/resources/page.tsx @@ -1,10 +1,30 @@ -import { Suspense } from "react"; -import { ResourcesClient } from "./ResourcesClient.js"; +import dynamic from "next/dynamic"; + +const ResourcesClient = dynamic( + () => import("./ResourcesClient.js").then((m) => m.ResourcesClient), + { + loading: () => ( +
+
+
+
+ {[...Array(10)].map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ), + }, +); export default function ResourcesPage() { - return ( - - - - ); + return ; } diff --git a/apps/web/src/app/(app)/timeline/loading.tsx b/apps/web/src/app/(app)/timeline/loading.tsx index 90dfaad..f3751d4 100644 --- a/apps/web/src/app/(app)/timeline/loading.tsx +++ b/apps/web/src/app/(app)/timeline/loading.tsx @@ -1,49 +1,49 @@ export default function TimelineLoading() { return ( -
+
{/* Toolbar */}
-
-
+
+
-
-
-
+
+
+
{/* Date header */}
-
+
{[...Array(20)].map((_, i) => ( -
+
))}
{/* Resource rows */} {[...Array(8)].map((_, i) => ( -
+
{/* Resource name cell */}
-
-
+
+
{/* Allocation bars */}
{i % 3 === 0 && ( -
+
)} {i % 3 === 1 && ( <> -
-
+
+
)} {i % 3 === 2 && ( -
+
)}
diff --git a/apps/web/src/app/(app)/timeline/page.tsx b/apps/web/src/app/(app)/timeline/page.tsx index 00e149f..6f8e683 100644 --- a/apps/web/src/app/(app)/timeline/page.tsx +++ b/apps/web/src/app/(app)/timeline/page.tsx @@ -1,8 +1,21 @@ -import { TimelineView } from "~/components/timeline/TimelineView.js"; +import dynamic from "next/dynamic"; + +const TimelineView = dynamic( + () => import("~/components/timeline/TimelineView.js").then((m) => m.TimelineView), + { + loading: () => ( +
+
+
+
+
+ ), + }, +); export default function TimelinePage() { return ( -
+

Timeline

diff --git a/apps/web/src/app/(app)/vacations/my/page.tsx b/apps/web/src/app/(app)/vacations/my/page.tsx index 829bc04..c5c5279 100644 --- a/apps/web/src/app/(app)/vacations/my/page.tsx +++ b/apps/web/src/app/(app)/vacations/my/page.tsx @@ -1,6 +1,6 @@ import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js"; -export const metadata = { title: "My Vacations — Planarchy" }; +export const metadata = { title: "My Vacations — plANARCHY" }; export default function MyVacationsPage() { return ; diff --git a/apps/web/src/app/api/cron/chargeability-alerts/route.ts b/apps/web/src/app/api/cron/chargeability-alerts/route.ts new file mode 100644 index 0000000..d5c8ac5 --- /dev/null +++ b/apps/web/src/app/api/cron/chargeability-alerts/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { checkChargeabilityAlerts } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/chargeability-alerts + * + * Finds resources whose current-month chargeability is >15 percentage points + * below their target and creates in-app notifications for managers. + * + * Duplicate-safe: only one alert per resource per month. + * + * Optionally protect with CRON_SECRET environment variable. + * When set, requests must include `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alertsSent = await checkChargeabilityAlerts(prisma as any); + + return NextResponse.json({ + ok: true, + alertsSent, + checkedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[cron/chargeability-alerts] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/cron/estimate-reminders/route.ts b/apps/web/src/app/api/cron/estimate-reminders/route.ts new file mode 100644 index 0000000..f55f82c --- /dev/null +++ b/apps/web/src/app/api/cron/estimate-reminders/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { checkPendingEstimateReminders } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/estimate-reminders + * + * Scans for estimates that have been in SUBMITTED status for more than 3 days + * without approval and creates in-app reminder notifications for managers. + * + * Intended to be called by an external cron job (e.g. `curl http://host/api/cron/estimate-reminders`) + * or a scheduled task runner. + * + * Optionally protect this endpoint with a shared secret via the `CRON_SECRET` + * environment variable. When set, requests must include the header + * `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reminderCount = await checkPendingEstimateReminders(prisma as any); + + return NextResponse.json({ + ok: true, + remindersCreated: reminderCount, + checkedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[cron/estimate-reminders] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/cron/public-holidays/route.ts b/apps/web/src/app/api/cron/public-holidays/route.ts new file mode 100644 index 0000000..1a6ffef --- /dev/null +++ b/apps/web/src/app/api/cron/public-holidays/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { autoImportPublicHolidays } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/public-holidays?year=2027 + * + * Auto-imports public holidays for all active resources for a given year. + * Each resource's federal state determines which state-specific holidays apply. + * Duplicate-safe: existing holidays are skipped. + * + * Query params: + * - year (optional): defaults to next year + * + * Optionally protected with CRON_SECRET environment variable. + * When set, requests must include `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const { searchParams } = new URL(request.url); + const yearParam = searchParams.get("year"); + const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear() + 1; + + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json( + { error: "Invalid year parameter. Must be between 2000 and 2100." }, + { status: 400 }, + ); + } + + try { + const result = await autoImportPublicHolidays(prisma, year); + + return NextResponse.json({ + ok: true, + year: result.year, + holidaysCreated: result.holidaysCreated, + resourcesProcessed: result.resourcesProcessed, + skippedExisting: result.skippedExisting, + }); + } catch (error) { + console.error("[cron/public-holidays] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts new file mode 100644 index 0000000..d85242b --- /dev/null +++ b/apps/web/src/app/api/health/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function GET() { + return NextResponse.json({ + status: "ok", + timestamp: new Date().toISOString(), + }); +} diff --git a/apps/web/src/app/api/perf/route.ts b/apps/web/src/app/api/perf/route.ts new file mode 100644 index 0000000..2e5771d --- /dev/null +++ b/apps/web/src/app/api/perf/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { eventBus } from "@planarchy/api/sse"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/perf — Runtime performance metrics. + * + * Protected by CRON_SECRET header or query param. + * Returns Node.js memory usage, process uptime, and SSE connection count. + */ +export function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + + if (cronSecret) { + const url = new URL(request.url); + const headerToken = request.headers.get("authorization")?.replace("Bearer ", ""); + const queryToken = url.searchParams.get("token"); + + if (headerToken !== cronSecret && queryToken !== cronSecret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const mem = process.memoryUsage(); + + return NextResponse.json({ + timestamp: new Date().toISOString(), + uptime: { + seconds: Math.round(process.uptime()), + formatted: formatUptime(process.uptime()), + }, + memory: { + heapUsedMB: round(mem.heapUsed / 1024 / 1024), + heapTotalMB: round(mem.heapTotal / 1024 / 1024), + rssMB: round(mem.rss / 1024 / 1024), + externalMB: round(mem.external / 1024 / 1024), + arrayBuffersMB: round(mem.arrayBuffers / 1024 / 1024), + }, + sse: { + activeConnections: eventBus.subscriberCount, + }, + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + }); +} + +function round(n: number): number { + return Math.round(n * 100) / 100; +} + +function formatUptime(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + parts.push(`${s}s`); + return parts.join(" "); +} diff --git a/apps/web/src/app/api/ready/route.ts b/apps/web/src/app/api/ready/route.ts new file mode 100644 index 0000000..cbbe24e --- /dev/null +++ b/apps/web/src/app/api/ready/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { createConnection } from "net"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380"; + +async function checkPostgres(): Promise<"ok" | "error"> { + try { + await prisma.$queryRaw`SELECT 1`; + return "ok"; + } catch { + return "error"; + } +} + +/** + * Lightweight Redis PING check using a raw TCP socket. + * Avoids importing ioredis (which is only a dependency of @planarchy/api). + */ +async function checkRedis(): Promise<"ok" | "error"> { + return new Promise((resolve) => { + try { + const url = new URL(REDIS_URL); + const host = url.hostname || "localhost"; + const port = parseInt(url.port || "6379", 10); + const timeout = 3000; + + const socket = createConnection({ host, port }, () => { + // Send Redis PING command using RESP protocol + socket.write("*1\r\n$4\r\nPING\r\n"); + }); + + socket.setTimeout(timeout); + + socket.on("data", (data) => { + const response = data.toString(); + socket.destroy(); + // Redis responds with +PONG\r\n + resolve(response.includes("PONG") ? "ok" : "error"); + }); + + socket.on("timeout", () => { + socket.destroy(); + resolve("error"); + }); + + socket.on("error", () => { + socket.destroy(); + resolve("error"); + }); + } catch { + resolve("error"); + } + }); +} + +export async function GET() { + const [postgres, redis] = await Promise.all([ + checkPostgres(), + checkRedis(), + ]); + + const allHealthy = postgres === "ok" && redis === "ok"; + + return NextResponse.json( + { + status: allHealthy ? "ready" : "not_ready", + postgres, + redis, + }, + { status: allHealthy ? 200 : 503 }, + ); +} diff --git a/apps/web/src/app/api/sse/timeline/route.ts b/apps/web/src/app/api/sse/timeline/route.ts index 529f918..2f18dd4 100644 --- a/apps/web/src/app/api/sse/timeline/route.ts +++ b/apps/web/src/app/api/sse/timeline/route.ts @@ -1,7 +1,11 @@ import { eventBus } from "@planarchy/api/sse"; +import { startReminderScheduler } from "@planarchy/api/lib/reminder-scheduler"; import { SSE_EVENT_TYPES } from "@planarchy/shared"; import { auth } from "~/server/auth.js"; +// Start the reminder scheduler (idempotent — only starts once) +startReminderScheduler(); + export const dynamic = "force-dynamic"; export const runtime = "nodejs"; diff --git a/apps/web/src/app/api/trpc/[trpc]/route.ts b/apps/web/src/app/api/trpc/[trpc]/route.ts index 48d5321..b50a242 100644 --- a/apps/web/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web/src/app/api/trpc/[trpc]/route.ts @@ -1,10 +1,25 @@ -import { createTRPCContext } from "@planarchy/api"; +import { createTRPCContext, loadRoleDefaults } from "@planarchy/api"; import { appRouter } from "@planarchy/api/router"; import { prisma } from "@planarchy/db"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import type { NextRequest } from "next/server"; import { auth } from "~/server/auth.js"; +// Throttle lastActiveAt updates: max once per 60s per user +const lastActiveCache = new Map(); +const ACTIVITY_THROTTLE_MS = 60_000; + +function trackActivity(userId: string) { + const now = Date.now(); + const last = lastActiveCache.get(userId) ?? 0; + if (now - last < ACTIVITY_THROTTLE_MS) return; + lastActiveCache.set(userId, now); + prisma.user.update({ + where: { id: userId }, + data: { lastActiveAt: new Date(now) }, + }).catch(() => {/* ignore */}); +} + const handler = async (req: NextRequest) => { const session = await auth(); @@ -15,12 +30,18 @@ const handler = async (req: NextRequest) => { }) : null; + // Track user activity (throttled, fire-and-forget) + if (dbUser) trackActivity(dbUser.id); + + // Load configurable role defaults (cached, 60s TTL) + const roleDefaults = await loadRoleDefaults(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: any = { endpoint: "/api/trpc", req, router: appRouter, - createContext: () => createTRPCContext({ session, dbUser }), + createContext: () => createTRPCContext({ session, dbUser, roleDefaults }), }; if (process.env["NODE_ENV"] === "development") { diff --git a/apps/web/src/app/auth/signin/page.tsx b/apps/web/src/app/auth/signin/page.tsx index f98efa2..3eb9acf 100644 --- a/apps/web/src/app/auth/signin/page.tsx +++ b/apps/web/src/app/auth/signin/page.tsx @@ -37,7 +37,7 @@ export default function SignInPage() {
- Planarchy Control Center + plANARCHY Control Center

Resource planning that stays readable under pressure. @@ -66,7 +66,7 @@ export default function SignInPage() {

Welcome Back

-

Sign in to Planarchy

+

Sign in to plANARCHY

Resource Planning, staffing, and forecasting.

@@ -87,7 +87,7 @@ export default function SignInPage() { value={email} onChange={(e) => setEmail(e.target.value)} className="app-input" - placeholder="admin@planarchy.dev" + placeholder="you@company.com" required />
@@ -116,14 +116,6 @@ export default function SignInPage() { -
-

Demo accounts

-
-

admin@planarchy.dev / admin123

-

manager@planarchy.dev / manager123

-

viewer@planarchy.dev / viewer123

-
-

diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx new file mode 100644 index 0000000..f737845 --- /dev/null +++ b/apps/web/src/app/global-error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + +
+

Something went wrong

+ +
+ + + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9084f9e..b67eda4 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -90,7 +90,7 @@ /* ─── Light Theme Surface Variables ─────────────────────────────────────── */ :root { - --surface-page: 244 246 250; + --surface-page: 246 247 251; --surface-card: 255 255 255; --surface-elevated: 249 250 252; --surface-input: 255 255 255; @@ -132,6 +132,8 @@ background-color 0.15s ease, color 0.15s ease; background-image: + radial-gradient(ellipse at 0% 0%, rgb(var(--accent-50) / 0.15), transparent 50%), + radial-gradient(ellipse at 100% 100%, rgb(var(--accent-100) / 0.08), transparent 50%), radial-gradient(circle at top left, rgb(var(--accent-100) / 0.32), transparent 24rem), linear-gradient(180deg, rgb(255 255 255 / 0.72), transparent 24rem); text-rendering: optimizeLegibility; @@ -168,13 +170,20 @@ font: inherit; } - input:focus-visible, - select:focus-visible, - textarea:focus-visible, - button:focus-visible, - a:focus-visible { + :focus-visible { outline: 2px solid rgb(var(--accent-500)); outline-offset: 2px; + border-radius: inherit; + } + + button:focus-visible, + a:focus-visible, + input:focus-visible, + select:focus-visible, + textarea:focus-visible { + outline: 2px solid rgb(var(--accent-500)); + outline-offset: 2px; + box-shadow: 0 0 0 3px rgb(var(--accent-500) / 0.12); } /* Scrollbar styling for dark mode */ @@ -335,7 +344,7 @@ color: rgb(196 181 253) !important; } .dark .bg-amber-50 { - background-color: rgb(120 53 15 / 0.2) !important; + background-color: rgb(120 53 15) !important; } /* Modal / overlay */ @@ -345,16 +354,28 @@ @layer components { .app-surface { - @apply rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900/90; + @apply rounded-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900/90; + --tw-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + :is(.dark) .app-surface { + --tw-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 4px 12px rgba(0, 0, 0, 0.15); } .app-surface-strong { - @apply rounded-3xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900; + @apply rounded-3xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900; + --tw-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.04); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + :is(.dark) .app-surface-strong { + --tw-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.2); } .app-toolbar { - @apply rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm backdrop-blur; - @apply dark:border-gray-700 dark:bg-gray-900/90 dark:shadow-black/20; + @apply sticky top-0 z-10 rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm backdrop-blur-sm; + @apply dark:border-gray-700 dark:bg-gray-900/80 dark:shadow-black/20; } .app-input { @@ -416,14 +437,122 @@ .allocation-block { @apply absolute rounded-md text-xs font-medium px-2 py-1 cursor-pointer select-none; - @apply transition-all duration-150 ease-in-out; + transition: transform 0.1s ease-out, box-shadow 0.1s ease-out, opacity 0.15s ease-in-out; } .allocation-block:hover { - @apply ring-2 ring-white ring-offset-1; + @apply ring-2 ring-white ring-offset-1 shadow-md; + transform: scale(1.02); } .allocation-block.dragging { @apply opacity-75 shadow-lg scale-105; } } + +/* ─── Overbooking blink animation ──────────────────────────────────────────── */ +@keyframes overbooking-blink { + 0%, 100% { background-color: rgba(239, 68, 68, 0); } + 50% { background-color: rgba(239, 68, 68, 0.18); } +} +.dark .animate-overbooking-blink { + animation: overbooking-blink-dark 2s ease-in-out infinite; +} +@keyframes overbooking-blink-dark { + 0%, 100% { background-color: rgba(239, 68, 68, 0); } + 50% { background-color: rgba(239, 68, 68, 0.25); } +} +.animate-overbooking-blink { + animation: overbooking-blink 2s ease-in-out infinite; +} + +/* ─── Shimmer skeleton animation ─────────────────────────────────────────── */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.shimmer-skeleton { + background: linear-gradient( + 90deg, + var(--surface-card) 25%, + color-mix(in srgb, var(--text-very-muted) 8%, var(--surface-card)) 50%, + var(--surface-card) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.8s ease-in-out infinite; +} + +/* ─── Table row stagger entrance ─────────────────────────────────────────── */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-row-enter { + animation: fadeSlideIn 0.25s ease-out both; +} + +/* ─── Subtle hover lift for cards and table rows ─────────────────────────── */ +.hover-lift { + transition: transform 0.15s ease-out, box-shadow 0.15s ease-out; +} +.hover-lift:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); +} + +:is(.dark) .hover-lift:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +/* ─── Smooth scroll + reduced-motion accessibility ────────────────────────── */ +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ─── Table row hover accent border ──────────────────────────────────────── */ +.table-row-hover { + border-left: 2px solid transparent; + transition: border-color 0.15s ease-out, background-color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; +} +.table-row-hover:hover { + border-left-color: rgb(var(--accent-400)); +} + +/* ─── Animated underline for action links ─────────────────────────────────── */ +.link-hover-underline { + position: relative; +} +.link-hover-underline::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 0; + height: 1px; + background: currentColor; + transition: width 0.2s ease-out; +} +.link-hover-underline:hover::after { + width: 100%; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3a40471..f1cff42 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,8 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Manrope, Source_Sans_3 } from "next/font/google"; import { TRPCProvider } from "~/lib/trpc/provider.js"; +import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js"; +import { InstallPrompt } from "~/components/layout/InstallPrompt.js"; import "./globals.css"; const uiFont = Source_Sans_3({ @@ -16,8 +18,31 @@ const displayFont = Manrope({ }); export const metadata: Metadata = { - title: "Planarchy — Resource Planning", + metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"), + title: "plANARCHY — Resource Planning", description: "Interactive resource planning and project staffing tool", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Planarchy", + }, + openGraph: { + title: "plANARCHY — Resource Planning", + description: "Estimates, staffing, chargeability, and timelines in one workspace.", + images: [{ url: "/og-image.png", width: 1024, height: 1024, alt: "plANARCHY Logo" }], + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "plANARCHY — Resource Planning", + description: "Estimates, staffing, chargeability, and timelines in one workspace.", + images: ["/og-image.png"], + }, +}; + +export const viewport: Viewport = { + themeColor: "#0284c7", }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -34,6 +59,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + + ); diff --git a/apps/web/src/components/admin/BatchSkillImport.tsx b/apps/web/src/components/admin/BatchSkillImport.tsx index 7a6a51e..c28511d 100644 --- a/apps/web/src/components/admin/BatchSkillImport.tsx +++ b/apps/web/src/components/admin/BatchSkillImport.tsx @@ -55,7 +55,7 @@ export function BatchSkillImport() { try { const buffer = await file.arrayBuffer(); - const result = parseSkillMatrixWorkbook(buffer); + const result = await parseSkillMatrixWorkbook(buffer); let roleId: string | undefined; let matchedRoleName: string | undefined; diff --git a/apps/web/src/components/admin/CalculationRulesClient.tsx b/apps/web/src/components/admin/CalculationRulesClient.tsx index 7d7e586..8811190 100644 --- a/apps/web/src/components/admin/CalculationRulesClient.tsx +++ b/apps/web/src/components/admin/CalculationRulesClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; const TRIGGER_TYPES = ["SICK", "VACATION", "PUBLIC_HOLIDAY", "CUSTOM"] as const; @@ -164,13 +165,27 @@ export function CalculationRulesClient() { - - - - - - - + + + + + + + @@ -233,7 +248,7 @@ export function CalculationRulesClient() {
- + setEditing({ ...editing, name: e.target.value })} @@ -241,7 +256,7 @@ export function CalculationRulesClient() { />
- +
NameTriggerCost EffectChargeabilityScopePriorityActive + Name + + Trigger + + Cost Effect + + Chargeability + + Scope + + Priority + + Active + Actions