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 @@
+///
Planarchy requires an internet connection. Please check your network and try again.
+ ++ Anomaly detection and AI-generated project narratives +
+
- Estimate detail
+ Estimate detail
+
No scope rows captured yet.
) : ( @@ -238,13 +239,13 @@ function EstimateDetailPanel({+
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.
+ Explore alternate staffing configurations for{" "} + {baseline.project.name}{" "} + and see instant cost/schedule impact. +
+Welcome Back
-Resource Planning, staffing, and forecasting.
Demo accounts
-admin@planarchy.dev / admin123
-manager@planarchy.dev / manager123
-viewer@planarchy.dev / viewer123
-