Merge pull request 'feat: Alive Enterprise Redesign — Sprints 0-5 + perf + bug fixes' (#16) from feature/alive-enterprise-redesign into main
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,128 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Planarchy — Offline</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; background: #f8fafc; color: #334155;
|
||||
}
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #0284c7; }
|
||||
p { font-size: 1rem; margin-bottom: 1.5rem; color: #64748b; }
|
||||
button {
|
||||
padding: 0.625rem 1.5rem; border: none; border-radius: 0.5rem;
|
||||
background: #0284c7; color: white; font-size: 0.875rem;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #0369a1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>You are offline</h1>
|
||||
<p>Planarchy requires an internet connection. Please check your network and try again.</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// 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))
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BroadcastManagementClient } from "~/components/notifications/BroadcastManagementClient.js";
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
return <BroadcastManagementClient />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const SystemRolesClient = dynamic(
|
||||
() => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="p-6 space-y-4 max-w-4xl mx-auto">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 w-full shimmer-skeleton rounded-xl animate-row-enter" style={{ animationDelay: `${i * 50}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function SystemRolesPage() {
|
||||
return <SystemRolesClient />;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WebhooksClient } from "~/components/admin/WebhooksClient.js";
|
||||
|
||||
export default function AdminWebhooksPage() {
|
||||
return <WebhooksClient />;
|
||||
}
|
||||
@@ -1,43 +1,43 @@
|
||||
export default function AllocationsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-36 shimmer-skeleton rounded" />
|
||||
<div className="h-9 w-32 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-28 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800 animate-row-enter" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-28 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-5 w-20 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
<div className="h-10 w-full shimmer-skeleton rounded" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="h-12 w-full shimmer-skeleton rounded animate-row-enter" style={{ animationDelay: `${i * 50}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function AllocationsPage() {
|
||||
return <AllocationsClient />;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import ComputationGraphClient from "~/components/analytics/ComputationGraphClient";
|
||||
|
||||
export default function ComputationGraphPage() {
|
||||
return <ComputationGraphClient />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InsightsPanel } from "~/components/analytics/InsightsPanel.js";
|
||||
|
||||
export default function InsightsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
AI Insights
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Anomaly detection and AI-generated project narratives
|
||||
</p>
|
||||
</div>
|
||||
<InsightsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js";
|
||||
|
||||
export default function SkillMarketplacePage() {
|
||||
return <SkillMarketplace />;
|
||||
}
|
||||
@@ -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<EstimateStatus, string> = {
|
||||
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<EstimateVersionStatus, string> = {
|
||||
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({
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
|
||||
Estimate detail
|
||||
Estimate detail <InfoTooltip content="Pre-project cost and effort calculation. Estimates model staffing demand, scope, and financials before work begins." />
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{estimate.name}
|
||||
@@ -145,7 +146,7 @@ function EstimateDetailPanel({
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
href={`/estimates/${estimate.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 dark:border-sky-700 bg-brand-50 dark:bg-sky-950/40 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-100 dark:hover:bg-sky-900/40"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
@@ -164,7 +165,7 @@ function EstimateDetailPanel({
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Version {latestVersion.versionNumber}
|
||||
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
|
||||
</span>
|
||||
@@ -205,13 +206,13 @@ function EstimateDetailPanel({
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Scope items
|
||||
Scope items <InfoTooltip content="Deliverables or work packages that define what is included in this estimate." />
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.scopeItems.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
|
||||
No scope rows captured yet.
|
||||
</p>
|
||||
) : (
|
||||
@@ -238,13 +239,13 @@ function EstimateDetailPanel({
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Demand lines
|
||||
Demand lines <InfoTooltip content="Staffing demand rows. Each line represents a role or resource with hours, cost rate, and sell rate." />
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.demandLines.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
|
||||
No staffing demand captured yet.
|
||||
</p>
|
||||
) : (
|
||||
@@ -272,7 +273,7 @@ function EstimateDetailPanel({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-6 text-sm text-gray-400">
|
||||
No versions available for this estimate yet.
|
||||
</p>
|
||||
)}
|
||||
@@ -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("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{estimate.project.shortCode}
|
||||
</span>
|
||||
)}
|
||||
@@ -344,13 +345,13 @@ function EstimateCard({
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference ID linking this estimate to a sales opportunity." /></p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{estimate.opportunityId ?? "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated <InfoTooltip content="When this estimate or any of its versions was last modified." /></p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{formatDateLong(estimate.updatedAt)}
|
||||
</p>
|
||||
@@ -407,7 +408,7 @@ export function EstimatesClient() {
|
||||
return (
|
||||
<>
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
|
||||
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
|
||||
@@ -465,7 +466,7 @@ export function EstimatesClient() {
|
||||
No estimates yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||
Start with the wizard to create a connected estimate from Planarchy data.
|
||||
Start with the wizard to create a connected estimate from plANARCHY data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export default function AppLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4 animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-72" />
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 shimmer-skeleton rounded w-48" />
|
||||
<div className="h-4 shimmer-skeleton rounded w-72" />
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
<div key={i} className="h-20 shimmer-skeleton rounded-xl" style={{ animationDelay: `${i * 50}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
<div className="h-48 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
<div className="h-64 shimmer-skeleton rounded-xl" />
|
||||
<div className="h-48 shimmer-skeleton rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { NotificationCenterClient } from "~/components/notifications/NotificationCenterClient.js";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<NotificationCenterClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-w-[104px] space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
|
||||
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
|
||||
<div className={clsx("h-full rounded-full transition-all duration-700 ease-out", barColor)} style={{ width: `${cappedPercent}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
||||
</div>
|
||||
@@ -116,9 +119,12 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
<motion.div
|
||||
ref={panelRef}
|
||||
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||
initial={{ opacity: 0, scaleY: 0.9 }}
|
||||
animate={{ opacity: 1, scaleY: 1 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className="fixed z-[9999] min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900 origin-top"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
@@ -139,7 +145,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
@@ -162,6 +168,8 @@ interface ProjectRow {
|
||||
totalPersonDays: number;
|
||||
utilizationPercent: number;
|
||||
dynamicFields?: Record<string, unknown> | null;
|
||||
coverImageUrl?: string | null;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
@@ -176,6 +184,7 @@ export function ProjectsClient() {
|
||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(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 (
|
||||
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
|
||||
{project.name}
|
||||
<Link href={`/projects/${project.id}`} className="inline-flex items-center gap-2 transition hover:text-brand-600 hover:underline">
|
||||
{project.coverImageUrl ? (
|
||||
<Image src={project.coverImageUrl} alt={project.name} width={24} height={24} className="h-6 w-6 flex-shrink-0 rounded object-cover" unoptimized={project.coverImageUrl.startsWith("data:")} />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-[9px] font-bold opacity-60"
|
||||
style={{
|
||||
backgroundColor: (project.color ?? "#6366f1") + "22",
|
||||
color: project.color ?? "#6366f1",
|
||||
}}
|
||||
>
|
||||
{project.name.split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{project.name}</span>
|
||||
</Link>
|
||||
</td>
|
||||
);
|
||||
@@ -385,15 +433,24 @@ export function ProjectsClient() {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 min-w-[120px]">
|
||||
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
||||
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
{formatMoney(project.budgetCents)}
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
</td>
|
||||
);
|
||||
case "allocations":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300">
|
||||
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
|
||||
<td key={col.key} className="px-4 py-3 text-right text-sm">
|
||||
{project.totalPersonDays > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||
<svg className="h-3 w-3 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{project.totalPersonDays}d
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
case "responsible":
|
||||
@@ -515,7 +572,7 @@ export function ProjectsClient() {
|
||||
|
||||
<div className="app-data-table">
|
||||
{isLoading ? (
|
||||
<div className="py-16 text-center text-sm text-gray-500 animate-pulse">Loading projects…</div>
|
||||
<div className="py-16 text-center text-sm text-gray-500 shimmer-skeleton">Loading projects…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -543,7 +600,7 @@ export function ProjectsClient() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{projects.map((project) => {
|
||||
{projects.map((project, index) => {
|
||||
const isSelected = selection.selectedIds.has(project.id);
|
||||
return (
|
||||
<DraggableTableRow
|
||||
@@ -551,7 +608,8 @@ export function ProjectsClient() {
|
||||
id={project.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => 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` }}
|
||||
>
|
||||
<td className="px-2 py-3 w-8">
|
||||
<button
|
||||
@@ -577,11 +635,11 @@ export function ProjectsClient() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(project as unknown as Project)}
|
||||
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100"
|
||||
className="link-hover-underline text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">
|
||||
<Link href={`/projects/${project.id}`} className="link-hover-underline text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200">
|
||||
View →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -652,12 +710,34 @@ export function ProjectsClient() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm batch delete */}
|
||||
{confirmBatchDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Projects"
|
||||
message={`Permanently delete ${confirmBatchDelete.length} project${confirmBatchDelete.length !== 1 ? "s" : ""}? This will also remove all associated allocations and demands. This action cannot be undone.`}
|
||||
confirmLabel="Delete All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
batchDeleteMutation.mutate({ ids: confirmBatchDelete });
|
||||
setConfirmBatchDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{ label: "Set Status…", onClick: () => 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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof trpc.project.getById>>;
|
||||
try {
|
||||
@@ -41,8 +48,18 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
||||
Back to Projects
|
||||
</Link>
|
||||
|
||||
{/* Cover Art */}
|
||||
<CoverArtSection
|
||||
projectId={project.id}
|
||||
coverImageUrl={project.coverImageUrl}
|
||||
coverFocusY={project.coverFocusY}
|
||||
projectColor={project.color}
|
||||
projectName={project.name}
|
||||
canEdit={canEditProject}
|
||||
/>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 dark:bg-gray-900 dark:border-gray-700">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
@@ -50,9 +67,11 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
<InfoTooltip content="Project lifecycle status: DRAFT = not yet visible, ACTIVE = in progress, ON_HOLD = paused, COMPLETED = finished, CANCELLED = abandoned." />
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
<InfoTooltip content="BD = Business Development (pre-sales), CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = non-project overhead." />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||
</div>
|
||||
@@ -63,38 +82,48 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
|
||||
{" — "}
|
||||
{formatDate(project.endDate)}
|
||||
</div>
|
||||
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
|
||||
<div className="mt-0.5 flex items-center">Win probability: {project.winProbability}%<InfoTooltip content="Likelihood of winning this project (0-100%). Used to calculate weighted pipeline value (budget x probability)." /></div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/projects/${id}/scenario`}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-indigo-300 bg-white px-3 py-2 text-sm font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 transition dark:border-indigo-600 dark:bg-gray-800 dark:text-indigo-300 dark:hover:bg-indigo-900/20"
|
||||
title="Open What-If Scenario Planner"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
What-If
|
||||
</Link>
|
||||
<ProjectDetailActions project={project as never} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Chargecode</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Chargecode<InfoTooltip content="Unique project identifier used for time tracking and cost attribution." /></dt>
|
||||
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Order Type</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Order Type<InfoTooltip content="BD = Business Development, CHARGEABLE = billable client work, INTERNAL = internal project, OVERHEAD = overhead costs." /></dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Allocation Type</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Allocation Type<InfoTooltip content="INT = staffed with internal resources, EXT = staffed with external contractors." /></dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Assignments</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Assignments<InfoTooltip content="Number of active resource assignments (confirmed or in-progress allocations) on this project." /></dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Open Demands</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Open Demands<InfoTooltip content="Staffing requirements that still need resources. Unfilled seats are demand positions not yet assigned to a person." /></dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">
|
||||
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
|
||||
</dd>
|
||||
</div>
|
||||
{project.responsiblePerson && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-xs text-gray-500">Responsible Person</dt>
|
||||
<dt className="text-xs text-gray-500 flex items-center">Responsible Person<InfoTooltip content="The project lead or account manager responsible for this project." /></dt>
|
||||
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<ReturnType<typeof trpc.scenario.getProjectBaseline>>;
|
||||
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 (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<Link
|
||||
href={`/projects/${id}`}
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to {baseline.project.name}
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
What-If Scenario Planner
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Explore alternate staffing configurations for{" "}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">{baseline.project.name}</span>{" "}
|
||||
and see instant cost/schedule impact.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScenarioPlanner
|
||||
projectId={id}
|
||||
baseline={baseline}
|
||||
resources={resources as never}
|
||||
roles={roles as never}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,48 @@
|
||||
export default function ProjectsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-28 shimmer-skeleton rounded" />
|
||||
<div className="h-9 w-28 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-32 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 flex-1 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-32 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-24 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-12 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800 animate-row-enter" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded font-mono" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-5 w-20 shimmer-skeleton rounded-full" />
|
||||
<div className="h-5 w-16 shimmer-skeleton rounded-full" />
|
||||
<div className="flex flex-col gap-1 w-24">
|
||||
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-2 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-2 w-full shimmer-skeleton rounded-full" />
|
||||
<div className="h-2 w-10 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-8 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-8 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReportBuilder } from "~/components/reports/ReportBuilder.js";
|
||||
|
||||
export default function ReportBuilderPage() {
|
||||
return <ReportBuilder />;
|
||||
}
|
||||
@@ -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 */}
|
||||
<div className="app-data-table">
|
||||
{isLoading && resources.length === 0 ? (
|
||||
<div className="p-12 text-center text-sm text-gray-500 animate-pulse">
|
||||
<div className="p-12 text-center text-sm text-gray-500 shimmer-skeleton">
|
||||
Loading resources…
|
||||
</div>
|
||||
) : (
|
||||
@@ -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() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{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` }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
@@ -1146,20 +1170,51 @@ export function ResourcesClient() {
|
||||
{resource.eid}
|
||||
</td>
|
||||
);
|
||||
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 (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<Link
|
||||
href={`/resources/${resource.id}`}
|
||||
className="text-sm font-medium text-gray-900 transition-colors hover:text-brand-600 hover:underline dark:text-gray-100"
|
||||
className="inline-flex items-center gap-2.5 transition-colors hover:text-brand-600 group"
|
||||
>
|
||||
{resource.displayName}
|
||||
<span
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-gray-900 group-hover:text-brand-600 group-hover:underline dark:text-gray-100">
|
||||
{resource.displayName}
|
||||
</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{resource.email}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{resource.email}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "chapter":
|
||||
return (
|
||||
<td
|
||||
@@ -1175,7 +1230,7 @@ export function ResourcesClient() {
|
||||
key={col.key}
|
||||
className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{(resource.lcrCents / 100).toFixed(0)} {resource.currency}
|
||||
{formatMoney(resource.lcrCents, resource.currency)}
|
||||
</td>
|
||||
);
|
||||
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 (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
<td key={col.key} className="px-4 py-3 text-sm min-w-[120px]">
|
||||
<div>
|
||||
<span className={`font-medium ${color}`}>
|
||||
{actual != null ? `${actual}%` : "—"}
|
||||
@@ -1211,7 +1278,22 @@ export function ResourcesClient() {
|
||||
({expected}% exp.)
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400">Target: {target}%</div>
|
||||
{actual !== target && (
|
||||
<div className="text-xs text-gray-400">Target: {target}%</div>
|
||||
)}
|
||||
{actual != null && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<div className="h-[3px] flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ease-out ${barColor}`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
{isOverflow && (
|
||||
<span className="text-[9px] font-bold text-green-600 dark:text-green-400" title={`${actual}% actual`}>+</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
@@ -1296,7 +1378,7 @@ export function ResourcesClient() {
|
||||
{skills.slice(0, 3).map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-950/30 dark:text-brand-200"
|
||||
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-900/60 dark:text-brand-100"
|
||||
>
|
||||
{s.skill}
|
||||
</span>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
export default function ResourcesLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-9 w-28 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 flex-1 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-36 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-9 w-28 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-28 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-24 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-14 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-12 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800 animate-row-enter" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-8 w-8 shimmer-skeleton rounded-full" />
|
||||
<div className="h-4 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-14 shimmer-skeleton rounded" />
|
||||
<div className="h-5 w-20 shimmer-skeleton rounded-full" />
|
||||
<div className="h-5 w-12 shimmer-skeleton rounded-full" />
|
||||
<div className="flex gap-1 flex-1">
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-16 shimmer-skeleton rounded-full" />
|
||||
<div className="h-5 w-16 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
<div className="h-10 w-full shimmer-skeleton rounded" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 px-4 py-3 animate-row-enter"
|
||||
style={{ animationDelay: `${i * 50}ms` }}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full shimmer-skeleton flex-shrink-0" />
|
||||
<div className="h-4 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-48 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResourcesClient />
|
||||
</Suspense>
|
||||
);
|
||||
return <ResourcesClient />;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,49 @@
|
||||
export default function TimelineLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-0 animate-pulse">
|
||||
<div className="flex flex-col h-full gap-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-8 w-24 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-8 w-24 shimmer-skeleton rounded-lg" />
|
||||
<div className="flex-1" />
|
||||
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-8 w-20 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-8 w-8 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-8 w-8 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-8 w-20 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Date header */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="w-48 flex-shrink-0 px-4 py-2">
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-20 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
<div className="flex-1 flex gap-px py-2 px-2">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div key={i} className="flex-1 h-3 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div key={i} className="flex-1 h-3 shimmer-skeleton rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource rows */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex border-b border-gray-100 dark:border-gray-800 py-3">
|
||||
<div key={i} className="flex border-b border-gray-100 dark:border-gray-800 py-3 animate-row-enter" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
{/* Resource name cell */}
|
||||
<div className="w-48 flex-shrink-0 px-4 flex flex-col gap-1.5">
|
||||
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-2 w-12 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-24 shimmer-skeleton rounded" />
|
||||
<div className="h-2 w-12 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
{/* Allocation bars */}
|
||||
<div className="flex-1 relative px-2 flex items-center gap-1">
|
||||
{i % 3 === 0 && (
|
||||
<div className="h-7 rounded-lg bg-brand-100 dark:bg-brand-900/30" style={{ width: "35%", marginLeft: "10%" }} />
|
||||
<div className="h-7 rounded-lg shimmer-skeleton" style={{ width: "35%", marginLeft: "10%" }} />
|
||||
)}
|
||||
{i % 3 === 1 && (
|
||||
<>
|
||||
<div className="h-7 rounded-lg bg-purple-100 dark:bg-purple-900/30" style={{ width: "20%", marginLeft: "5%" }} />
|
||||
<div className="h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30" style={{ width: "30%", marginLeft: "2%" }} />
|
||||
<div className="h-7 rounded-lg shimmer-skeleton" style={{ width: "20%", marginLeft: "5%" }} />
|
||||
<div className="h-7 rounded-lg shimmer-skeleton" style={{ width: "30%", marginLeft: "2%" }} />
|
||||
</>
|
||||
)}
|
||||
{i % 3 === 2 && (
|
||||
<div className="h-7 rounded-lg bg-green-100 dark:bg-green-900/30" style={{ width: "45%", marginLeft: "20%" }} />
|
||||
<div className="h-7 rounded-lg shimmer-skeleton" style={{ width: "45%", marginLeft: "20%" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: () => (
|
||||
<div className="flex flex-col gap-4 h-full p-6">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
<div className="h-10 w-full shimmer-skeleton rounded" />
|
||||
<div className="flex-1 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export default function TimelinePage() {
|
||||
return (
|
||||
<div className="app-page flex h-full flex-col gap-5 pb-6">
|
||||
<div className="app-page flex max-h-[100dvh] flex-col gap-5 overflow-hidden pb-6">
|
||||
<div className="app-page-header">
|
||||
<div>
|
||||
<h1 className="app-page-title">Timeline</h1>
|
||||
|
||||
@@ -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 <MyVacationsClient />;
|
||||
|
||||
@@ -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 <secret>`.
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <secret>`.
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <secret>`.
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string, number>();
|
||||
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") {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function SignInPage() {
|
||||
<div className="hidden rounded-[2rem] border border-white/70 bg-white/75 p-10 shadow-2xl backdrop-blur lg:flex lg:flex-col lg:justify-between dark:border-slate-800 dark:bg-slate-950/60">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-900/50 dark:bg-brand-900/20 dark:text-brand-300">
|
||||
Planarchy Control Center
|
||||
plANARCHY Control Center
|
||||
</span>
|
||||
<h1 className="mt-6 font-display text-5xl font-semibold leading-tight text-gray-900 dark:text-gray-50">
|
||||
Resource planning that stays readable under pressure.
|
||||
@@ -66,7 +66,7 @@ export default function SignInPage() {
|
||||
<div className="app-surface-strong p-8">
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Welcome Back</p>
|
||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to Planarchy</h2>
|
||||
<h2 className="mt-3 font-display text-4xl font-semibold text-gray-900 dark:text-gray-50">Sign in to plANARCHY</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">Resource Planning, staffing, and forecasting.</p>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
@@ -116,14 +116,6 @@ export default function SignInPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-900/70">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">Demo accounts</p>
|
||||
<div className="space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p><span className="font-mono text-xs">admin@planarchy.dev</span> / admin123</p>
|
||||
<p><span className="font-mono text-xs">manager@planarchy.dev</span> / manager123</p>
|
||||
<p><span className="font-mono text-xs">viewer@planarchy.dev</span> / viewer123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<html>
|
||||
<body>
|
||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||
<h2>Something went wrong</h2>
|
||||
<button onClick={reset}>Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+142
-13
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
</head>
|
||||
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
<InstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Trigger</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Cost Effect</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Chargeability</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Scope</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Priority</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Active</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Name <InfoTooltip content="A descriptive label for this rule. Use clear names so admins can quickly identify what each rule does." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Trigger <InfoTooltip content="The absence type that activates this rule: Sick Leave, Vacation, Public Holiday, or Custom. Determines when the cost/chargeability logic applies." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Cost Effect <InfoTooltip content="How this absence affects project costs. 'Charge to Project' bills the project, 'No Project Cost' absorbs the cost centrally, 'Reduced Cost' applies a percentage discount." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Chargeability <InfoTooltip content="Whether the person's time counts as chargeable during this absence. 'Person Chargeable' includes the time in chargeability metrics; 'Not Chargeable' excludes it." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Scope <InfoTooltip content="Limits the rule to a specific project or order type (BD, Chargeable, Internal, Overhead). 'Global' means the rule applies to all projects." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Priority <InfoTooltip content="When multiple rules match the same absence, the one with the highest priority number wins. Use this to create specific overrides for certain projects." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">
|
||||
<span className="flex items-center">Active <InfoTooltip content="Only active rules are evaluated. Deactivate a rule to temporarily disable it without deleting." /></span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -233,7 +248,7 @@ export function CalculationRulesClient() {
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Name <InfoTooltip content="A unique, descriptive name for this calculation rule." /></label>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
@@ -241,7 +256,7 @@ export function CalculationRulesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Description <InfoTooltip content="Optional notes explaining when and why this rule exists." /></label>
|
||||
<textarea
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
@@ -251,7 +266,7 @@ export function CalculationRulesClient() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Trigger Type <InfoTooltip content="The type of absence event that activates this rule." /></label>
|
||||
<select
|
||||
value={editing.triggerType}
|
||||
onChange={(e) => setEditing({ ...editing, triggerType: e.target.value })}
|
||||
@@ -261,7 +276,7 @@ export function CalculationRulesClient() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional)</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Order Type (optional) <InfoTooltip content="Restricts this rule to a specific order type. Leave as 'All' to apply globally." /></label>
|
||||
<select
|
||||
value={editing.orderType}
|
||||
onChange={(e) => setEditing({ ...editing, orderType: e.target.value })}
|
||||
@@ -274,7 +289,7 @@ export function CalculationRulesClient() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Cost Effect <InfoTooltip content="Determines how costs are attributed during this absence. 'Charge' bills the project, 'Zero' removes cost, 'Reduce' applies a percentage reduction." /></label>
|
||||
<select
|
||||
value={editing.costEffect}
|
||||
onChange={(e) => setEditing({ ...editing, costEffect: e.target.value })}
|
||||
@@ -284,7 +299,7 @@ export function CalculationRulesClient() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Chargeability <InfoTooltip content="Controls whether this absence counts toward the person's chargeability KPI." /></label>
|
||||
<select
|
||||
value={editing.chargeabilityEffect}
|
||||
onChange={(e) => setEditing({ ...editing, chargeabilityEffect: e.target.value })}
|
||||
@@ -296,8 +311,8 @@ export function CalculationRulesClient() {
|
||||
</div>
|
||||
{editing.costEffect === "REDUCE" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reduction Percent (0-100)
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reduction Percent (0-100) <InfoTooltip content="The percentage by which the cost is reduced. E.g. 50 means the project is charged half the normal rate." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -311,7 +326,7 @@ export function CalculationRulesClient() {
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<label className="mb-1 flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">Priority <InfoTooltip content="Higher numbers take precedence when multiple rules match. Use 0 for default rules and higher values for specific overrides." /></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -328,7 +343,7 @@ export function CalculationRulesClient() {
|
||||
onChange={(e) => setEditing({ ...editing, isActive: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Active
|
||||
Active <InfoTooltip content="Inactive rules are ignored during cost calculations." />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type ClientRow = {
|
||||
@@ -166,7 +167,7 @@ export function ClientsAdminClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Client hierarchy for project assignment and chargeability reporting
|
||||
Client hierarchy for project assignment and chargeability reporting <InfoTooltip content="Clients are companies or brands that commission projects. The hierarchy supports parent/child relationships (e.g. BMW Group > BMW > MINI). Projects are assigned to clients for revenue tracking and chargeability reporting." />
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -220,7 +221,7 @@ export function ClientsAdminClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The full name of the client. Shown in project assignment dropdowns and reports." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
@@ -232,7 +233,7 @@ export function ClientsAdminClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="A short abbreviation for this client (e.g. BMW). Used in compact views and rate card assignments." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
@@ -242,7 +243,7 @@ export function ClientsAdminClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order. Lower numbers appear first." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
@@ -253,7 +254,7 @@ export function ClientsAdminClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client <InfoTooltip content="Set a parent to create a hierarchy (e.g. MINI under BMW Group). Child clients inherit the parent's reporting context." /></label>
|
||||
<select
|
||||
value={editing.parentId}
|
||||
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type CountryRow = {
|
||||
@@ -175,11 +176,11 @@ export function CountriesClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Daily Hours</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Schedule</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Cities</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="ISO country code (e.g. DE, ES, IN). Used to identify the country in exports and API calls." /></span></th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The full country name. Shown in dropdowns and resource location fields." /></span></th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Daily Hours <InfoTooltip content="Standard working hours per day for this country. Used in capacity calculations to convert between days and hours." /></span></th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Schedule <InfoTooltip content="Special schedule rules (e.g. Spain has reduced Friday hours and summer hours). 'Standard' uses the fixed daily hours value." /></span></th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Cities <InfoTooltip content="Metro cities within this country. Used for location-specific rate cards and resource assignment." /></span></th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -286,7 +287,7 @@ export function CountriesClient() {
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="2-3 letter ISO country code. Auto-uppercased." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
@@ -297,7 +298,7 @@ export function CountriesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours <InfoTooltip content="Standard working hours per day. Used to convert between hours and days in capacity calculations." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.dailyWorkingHours}
|
||||
@@ -311,7 +312,7 @@ export function CountriesClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="Full country name shown in the UI." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
@@ -330,14 +331,14 @@ export function CountriesClient() {
|
||||
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Variable schedule (Spain-type)
|
||||
Variable schedule (Spain-type) <InfoTooltip content="Enable for countries with variable working hours (e.g. reduced Friday/summer hours). Overrides the fixed daily hours with day-specific rules." />
|
||||
</label>
|
||||
|
||||
{editing.hasSpainRules && (
|
||||
<div className="mt-3 space-y-3 pl-6 border-l-2 border-amber-300 dark:border-amber-700">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Friday Hours <InfoTooltip content="Working hours on Fridays. Typically shorter than regular days." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.fridayHours}
|
||||
@@ -347,7 +348,7 @@ export function CountriesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu)</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu) <InfoTooltip content="Working hours Monday through Thursday outside the summer period." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.regularHours}
|
||||
@@ -359,7 +360,7 @@ export function CountriesClient() {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer From <InfoTooltip content="Start of the summer period (MM-DD format). During summer, reduced hours apply." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.summerFrom}
|
||||
@@ -369,7 +370,7 @@ export function CountriesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To <InfoTooltip content="End of the summer period (MM-DD format)." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.summerTo}
|
||||
@@ -379,7 +380,7 @@ export function CountriesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours <InfoTooltip content="Reduced daily working hours during the summer period." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.summerHours}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type EffortUnitMode = "per_frame" | "per_item" | "flat";
|
||||
@@ -184,7 +185,7 @@ export function EffortRulesClient() {
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Name <InfoTooltip content="A descriptive name for this rule set, e.g. 'CGI Standard Rules'. Used to identify the set when linking it to estimates." /></label>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
@@ -193,7 +194,7 @@ export function EffortRulesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Description <InfoTooltip content="Optional notes about when this rule set should be used." /></label>
|
||||
<input
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
@@ -210,7 +211,7 @@ export function EffortRulesClient() {
|
||||
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Default rule set (auto-selected for new estimates)
|
||||
Default rule set (auto-selected for new estimates) <InfoTooltip content="When checked, this set is pre-selected when creating new estimates. Only one set can be default." />
|
||||
</label>
|
||||
|
||||
{/* Rules table */}
|
||||
@@ -234,11 +235,11 @@ export function EffortRulesClient() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-2 font-medium">Scope type</th>
|
||||
<th className="px-2 py-2 font-medium">Discipline</th>
|
||||
<th className="px-2 py-2 font-medium">Chapter</th>
|
||||
<th className="px-2 py-2 font-medium">Unit mode</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Hours/unit</th>
|
||||
<th className="py-2 pr-2 font-medium"><span className="flex items-center">Scope type <InfoTooltip content="The type of deliverable this rule applies to: Shot, Asset, Environment, Sequence, or Other." /></span></th>
|
||||
<th className="px-2 py-2 font-medium"><span className="flex items-center">Discipline <InfoTooltip content="The production discipline (e.g. 3D Animation, Compositing) that this rule generates demand for." /></span></th>
|
||||
<th className="px-2 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Optional grouping within a discipline. Used to organize demand lines in the estimate staffing tab." /></span></th>
|
||||
<th className="px-2 py-2 font-medium"><span className="flex items-center">Unit mode <InfoTooltip content="How hours are calculated: 'Per frame' multiplies by frame count, 'Per item' by item count, 'Flat' is a fixed amount." /></span></th>
|
||||
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Hours/unit <InfoTooltip content="The number of hours per unit. Combined with the unit mode and scope item count, this pre-fills the total effort in estimates." /></span></th>
|
||||
<th className="pl-2 py-2 font-medium w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -392,11 +393,11 @@ export function EffortRulesClient() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Scope type</th>
|
||||
<th className="px-3 py-2 font-medium">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 font-medium">Unit mode</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Hours/unit</th>
|
||||
<th className="py-2 pr-3 font-medium"><span className="flex items-center">Scope type <InfoTooltip content="The deliverable type (Shot, Asset, etc.) this rule targets." /></span></th>
|
||||
<th className="px-3 py-2 font-medium"><span className="flex items-center">Discipline <InfoTooltip content="The production discipline this demand line is for." /></span></th>
|
||||
<th className="px-3 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Optional sub-grouping within the discipline." /></span></th>
|
||||
<th className="px-3 py-2 font-medium"><span className="flex items-center">Unit mode <InfoTooltip content="Per frame, per item, or flat hours calculation mode." /></span></th>
|
||||
<th className="pl-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Hours/unit <InfoTooltip content="Hours multiplied by the scope item count to compute total effort." /></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type EditingRule = {
|
||||
@@ -198,7 +199,7 @@ export function ExperienceMultipliersClient() {
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Name</label>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Name <InfoTooltip content="A descriptive name for this multiplier set. Used to identify it when applying multipliers to estimates." /></label>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
@@ -207,7 +208,7 @@ export function ExperienceMultipliersClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-gray-500 uppercase">Description <InfoTooltip content="Optional explanation of when this multiplier set should be used." /></label>
|
||||
<input
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
@@ -224,7 +225,7 @@ export function ExperienceMultipliersClient() {
|
||||
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Default set (auto-selected when applying multipliers)
|
||||
Default set (auto-selected when applying multipliers) <InfoTooltip content="When checked, this set is automatically selected when applying experience multipliers. Only one set can be default." />
|
||||
</label>
|
||||
|
||||
{/* Rules table */}
|
||||
@@ -248,13 +249,13 @@ export function ExperienceMultipliersClient() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-2 font-medium">Chapter</th>
|
||||
<th className="px-2 py-2 font-medium">Location</th>
|
||||
<th className="px-2 py-2 font-medium">Level</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Cost mult.</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Bill mult.</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Shoring %</th>
|
||||
<th className="px-2 py-2 text-right font-medium">Add. effort %</th>
|
||||
<th className="py-2 pr-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="The discipline/chapter this multiplier applies to. Leave blank to match all chapters." /></span></th>
|
||||
<th className="px-2 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="The country/location this multiplier targets. Used for nearshoring/offshoring cost adjustments." /></span></th>
|
||||
<th className="px-2 py-2 font-medium"><span className="flex items-center">Level <InfoTooltip content="The seniority level (Junior, Mid, Senior, etc.). Juniors typically need a higher effort multiplier." /></span></th>
|
||||
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Cost mult. <InfoTooltip content="Multiplier applied to cost rates. E.g. 0.5 means 50% of the base cost rate (cheaper location)." /></span></th>
|
||||
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Bill mult. <InfoTooltip content="Multiplier applied to billing rates. E.g. 0.8 means the client is billed at 80% of the standard rate." /></span></th>
|
||||
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Shoring % <InfoTooltip content="Ratio of work done at the remote location (0-1). E.g. 0.7 means 70% remote, 30% local." /></span></th>
|
||||
<th className="px-2 py-2 text-right font-medium"><span className="flex items-center justify-end">Add. effort % <InfoTooltip content="Additional effort overhead for coordination, e.g. 0.15 adds 15% extra hours for communication overhead." /></span></th>
|
||||
<th className="pl-2 py-2 font-medium w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -439,13 +440,13 @@ export function ExperienceMultipliersClient() {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 font-medium">Location</th>
|
||||
<th className="px-3 py-2 font-medium">Level</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost mult.</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Bill mult.</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Shoring</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Add. effort</th>
|
||||
<th className="py-2 pr-3 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="Discipline this multiplier applies to." /></span></th>
|
||||
<th className="px-3 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="Target country/region." /></span></th>
|
||||
<th className="px-3 py-2 font-medium"><span className="flex items-center">Level <InfoTooltip content="Seniority level filter." /></span></th>
|
||||
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Cost mult. <InfoTooltip content="Factor applied to cost rates." /></span></th>
|
||||
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Bill mult. <InfoTooltip content="Factor applied to billing rates." /></span></th>
|
||||
<th className="px-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Shoring <InfoTooltip content="Share of work done remotely (0-100%)." /></span></th>
|
||||
<th className="pl-3 py-2 text-right font-medium"><span className="flex items-center justify-end">Add. effort <InfoTooltip content="Extra effort overhead percentage." /></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type LevelRow = { id: string; name: string; groupId: string };
|
||||
@@ -115,7 +116,7 @@ export function ManagementLevelsClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Management Levels</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Level groups with chargeability targets and individual levels
|
||||
Level groups with chargeability targets and individual levels <InfoTooltip content="Management levels define seniority groups (e.g. Senior Management, Team Lead). Each group has a chargeability target that appears in chargeability reports. Individual levels within a group are assigned to resources." />
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -146,6 +147,7 @@ export function ManagementLevelsClient() {
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
|
||||
Target: {Math.round(group.targetPercentage * 100)}%
|
||||
</span>
|
||||
<InfoTooltip content="The chargeability target for this group. Resources in this group are expected to achieve this percentage of chargeable hours. Used in chargeability reports and dashboards." />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -217,7 +219,7 @@ export function ManagementLevelsClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The name of this management level group (e.g. Senior Management, Team Leads)." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingGroup.name}
|
||||
@@ -229,7 +231,7 @@ export function ManagementLevelsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100)</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Target % (0-100) <InfoTooltip content="The chargeability target for resources in this group. Enter as a percentage (e.g. 70 for 70%). Used in chargeability reports." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(editingGroup.targetPercentage * 100)}
|
||||
@@ -240,7 +242,7 @@ export function ManagementLevelsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order of groups. Lower numbers appear first." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingGroup.sortOrder}
|
||||
@@ -279,7 +281,7 @@ export function ManagementLevelsClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Level Name <InfoTooltip content="The specific title within the group (e.g. Managing Director, VP). This is assigned to individual resources." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLevel.name}
|
||||
@@ -290,7 +292,7 @@ export function ManagementLevelsClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group <InfoTooltip content="The management level group this level belongs to. Determines chargeability target and reporting." /></label>
|
||||
<select
|
||||
value={editingLevel.groupId}
|
||||
onChange={(e) => setEditingLevel({ ...editingLevel, groupId: e.target.value })}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type OrgUnitRow = {
|
||||
@@ -166,7 +167,7 @@ export function OrgUnitsClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Org Units</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
3-level hierarchy: L5 (Division) → L6 (Department) → L7 (Team)
|
||||
3-level hierarchy: L5 (Division) → L6 (Department) → L7 (Team) <InfoTooltip content="Org units define the organizational structure. Resources are assigned to L7 teams, which roll up to L6 departments and L5 divisions. Used for capacity planning, reporting, and access control." />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -206,7 +207,7 @@ export function OrgUnitsClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The full name of this organizational unit." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
@@ -218,7 +219,7 @@ export function OrgUnitsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Short Name <InfoTooltip content="An abbreviation for compact views and exports (e.g. 'CP' for Content Production)." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.shortName}
|
||||
@@ -228,7 +229,7 @@ export function OrgUnitsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order among siblings. Lower numbers appear first." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
@@ -240,7 +241,7 @@ export function OrgUnitsClient() {
|
||||
|
||||
{editing.level > 5 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit <InfoTooltip content="The parent org unit in the hierarchy. L6 departments must belong to an L5 division; L7 teams must belong to an L6 department." /></label>
|
||||
<select
|
||||
value={editing.parentId}
|
||||
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatCents } from "~/lib/format.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────────────────
|
||||
@@ -486,14 +487,14 @@ export function RateCardsClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="px-4 py-2 font-medium">Chapter</th>
|
||||
<th className="px-4 py-2 font-medium">Location</th>
|
||||
<th className="px-4 py-2 font-medium">Seniority</th>
|
||||
<th className="px-4 py-2 font-medium">Work Type</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Cost Rate</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Bill Rate</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Machine Rate</th>
|
||||
<th className="px-4 py-2 font-medium"><span className="flex items-center">Role <InfoTooltip content="The job role this rate applies to. Leave empty if the rate is defined by chapter/seniority instead." /></span></th>
|
||||
<th className="px-4 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="The production discipline (e.g. Animation, Compositing). Narrows which allocations use this rate." /></span></th>
|
||||
<th className="px-4 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="Geographic location filter. Used when rates vary by region (e.g. Munich vs. Barcelona)." /></span></th>
|
||||
<th className="px-4 py-2 font-medium"><span className="flex items-center">Seniority <InfoTooltip content="Experience level (Junior, Mid, Senior, etc.). Higher seniority typically has higher rates." /></span></th>
|
||||
<th className="px-4 py-2 font-medium"><span className="flex items-center">Work Type <InfoTooltip content="Type of work arrangement (e.g. Onsite, Remote). Can affect rate pricing." /></span></th>
|
||||
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Cost Rate <InfoTooltip content="Internal hourly cost in cents. This is what the company actually pays. Used in budget calculations and profitability analysis." /></span></th>
|
||||
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Bill Rate <InfoTooltip content="External hourly billing rate in cents. This is what the client is charged. The difference between bill rate and cost rate is the margin." /></span></th>
|
||||
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Machine Rate <InfoTooltip content="Hourly cost for compute/render resources in cents. Added on top of personnel costs for roles that require heavy rendering." /></span></th>
|
||||
<th className="px-4 py-2 font-medium w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -558,7 +559,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="A descriptive name for this rate card, typically including the year or client name." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.name}
|
||||
@@ -569,7 +570,7 @@ export function RateCardsClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client <InfoTooltip content="Optionally tie this rate card to a specific client. Client-specific cards override the default rates for that client's projects." /></label>
|
||||
<select
|
||||
value={editingCard.clientId}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, clientId: e.target.value })}
|
||||
@@ -586,7 +587,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency <InfoTooltip content="3-letter ISO currency code (e.g. EUR, USD). All rates in this card use this currency." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.currency}
|
||||
@@ -597,7 +598,7 @@ export function RateCardsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source <InfoTooltip content="Where these rates came from (e.g. Finance dept, Client contract). For documentation only." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.source}
|
||||
@@ -610,7 +611,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From <InfoTooltip content="Start date of this rate card's validity. Allocations before this date will not use these rates." /></label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveFrom}
|
||||
@@ -619,7 +620,7 @@ export function RateCardsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To <InfoTooltip content="End date of this rate card's validity. Leave empty for open-ended validity." /></label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveTo}
|
||||
@@ -658,7 +659,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role <InfoTooltip content="The job role this rate line applies to. Rates are matched to allocations by role, chapter, seniority, and location." /></label>
|
||||
<select
|
||||
value={editingLine.roleId}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, roleId: e.target.value })}
|
||||
@@ -673,7 +674,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter <InfoTooltip content="Production discipline this rate applies to." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.chapter}
|
||||
@@ -683,7 +684,7 @@ export function RateCardsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location <InfoTooltip content="Geographic location for region-specific rate pricing." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.location}
|
||||
@@ -696,7 +697,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority <InfoTooltip content="Experience level filter for this rate line." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.seniority}
|
||||
@@ -706,7 +707,7 @@ export function RateCardsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type <InfoTooltip content="Work arrangement type (e.g. Onsite, Remote)." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.workType}
|
||||
@@ -718,7 +719,7 @@ export function RateCardsClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group <InfoTooltip content="Broad service category (e.g. Post Production, VFX). Used for grouping rates in reports." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.serviceGroup}
|
||||
@@ -730,7 +731,7 @@ export function RateCardsClient() {
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents)</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents) <InfoTooltip content="Internal hourly cost in cents. E.g. 7500 = 75.00 EUR/h. This is the company's actual cost and flows into budget calculations." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.costRateCents}
|
||||
@@ -741,7 +742,7 @@ export function RateCardsClient() {
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.costRateCents)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents)</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents) <InfoTooltip content="Hourly rate charged to the client in cents. The margin is bill rate minus cost rate." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.billRateCents}
|
||||
@@ -752,7 +753,7 @@ export function RateCardsClient() {
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.billRateCents)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents)</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents) <InfoTooltip content="Hourly compute/render cost in cents. Added on top of personnel costs for render-heavy roles." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.machineRateCents}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PermissionKey } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
viewCosts: "View Costs",
|
||||
exportData: "Export Data",
|
||||
importData: "Import Data",
|
||||
approveVacations: "Approve Vacations",
|
||||
manageBlueprints: "Manage Blueprints",
|
||||
viewAllResources: "View All Resources",
|
||||
manageResources: "Manage Resources",
|
||||
manageProjects: "Manage Projects",
|
||||
manageAllocations: "Manage Allocations",
|
||||
manageRoles: "Manage Roles",
|
||||
manageUsers: "Manage Users",
|
||||
viewScores: "View Scores",
|
||||
};
|
||||
|
||||
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||
viewCosts: "Access to cost data, budget views, and financial reports",
|
||||
exportData: "Export data to Excel, CSV, or PDF formats",
|
||||
importData: "Import data from external sources (Dispo, Excel)",
|
||||
approveVacations: "Approve or reject vacation requests",
|
||||
manageBlueprints: "Create and edit blueprint field definitions",
|
||||
viewAllResources: "View all resources (not just own team)",
|
||||
manageResources: "Create, edit, and deactivate resource records",
|
||||
manageProjects: "Create, edit, and manage project records",
|
||||
manageAllocations: "Create, edit, and delete allocations",
|
||||
manageRoles: "Create and edit project roles",
|
||||
manageUsers: "Manage user accounts and permissions",
|
||||
viewScores: "View value scores and skill analytics",
|
||||
};
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: "purple", label: "Purple", class: "bg-purple-500" },
|
||||
{ value: "blue", label: "Blue", class: "bg-blue-500" },
|
||||
{ value: "amber", label: "Amber", class: "bg-amber-500" },
|
||||
{ value: "green", label: "Green", class: "bg-green-500" },
|
||||
{ value: "red", label: "Red", class: "bg-red-500" },
|
||||
{ value: "gray", label: "Gray", class: "bg-gray-500" },
|
||||
{ value: "indigo", label: "Indigo", class: "bg-indigo-500" },
|
||||
{ value: "teal", label: "Teal", class: "bg-teal-500" },
|
||||
];
|
||||
|
||||
const ROLE_COLOR_MAP: Record<string, string> = {
|
||||
purple: "border-purple-300 bg-purple-50 dark:border-purple-700 dark:bg-purple-900/20",
|
||||
blue: "border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20",
|
||||
amber: "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20",
|
||||
green: "border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/20",
|
||||
red: "border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20",
|
||||
gray: "border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50",
|
||||
indigo: "border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-900/20",
|
||||
teal: "border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-900/20",
|
||||
};
|
||||
|
||||
const ROLE_BADGE_MAP: Record<string, string> = {
|
||||
purple: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
|
||||
blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
||||
amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
||||
green: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
|
||||
red: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
|
||||
gray: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400",
|
||||
teal: "bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400",
|
||||
};
|
||||
|
||||
type RoleConfig = {
|
||||
role: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
defaultPermissions: unknown;
|
||||
color: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type EditingRole = {
|
||||
role: string;
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
permissions: Set<string>;
|
||||
};
|
||||
|
||||
export function SystemRolesClient() {
|
||||
const [editingRole, setEditingRole] = useState<EditingRole | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: roleConfigs, isLoading } = trpc.systemRoleConfig.list.useQuery(undefined, {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.systemRoleConfig.list.invalidate();
|
||||
setEditingRole(null);
|
||||
setActionError(null);
|
||||
setSuccessMessage("Role permissions updated successfully");
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
function openEdit(config: RoleConfig) {
|
||||
setEditingRole({
|
||||
role: config.role,
|
||||
label: config.label,
|
||||
description: config.description ?? "",
|
||||
color: config.color ?? "gray",
|
||||
permissions: new Set(config.defaultPermissions as string[]),
|
||||
});
|
||||
setActionError(null);
|
||||
setSuccessMessage(null);
|
||||
}
|
||||
|
||||
function togglePermission(key: string) {
|
||||
if (!editingRole) return;
|
||||
const next = new Set(editingRole.permissions);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
setEditingRole({ ...editingRole, permissions: next });
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (!editingRole) return;
|
||||
setEditingRole({ ...editingRole, permissions: new Set(ALL_PERMISSION_KEYS) });
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
if (!editingRole) return;
|
||||
setEditingRole({ ...editingRole, permissions: new Set() });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editingRole) return;
|
||||
setActionError(null);
|
||||
await updateMutation.mutateAsync({
|
||||
role: editingRole.role,
|
||||
label: editingRole.label,
|
||||
description: editingRole.description || null,
|
||||
color: editingRole.color,
|
||||
defaultPermissions: Array.from(editingRole.permissions),
|
||||
});
|
||||
}
|
||||
|
||||
const configs = (roleConfigs ?? []) as unknown as RoleConfig[];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">System Role Management</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure default permissions for each system role. Changes apply to all users with that role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-24 shimmer-skeleton rounded-xl animate-row-enter" style={{ animationDelay: `${i * 50}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role Cards */}
|
||||
<div className="space-y-3">
|
||||
{configs.map((config) => {
|
||||
const perms = config.defaultPermissions as string[];
|
||||
const color = config.color ?? "gray";
|
||||
return (
|
||||
<div
|
||||
key={config.role}
|
||||
className={`rounded-xl border-2 p-4 transition-colors ${ROLE_COLOR_MAP[color] ?? ROLE_COLOR_MAP.gray}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{config.role}
|
||||
</span>
|
||||
</div>
|
||||
{config.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{perms.length === 0 ? (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No default permissions</span>
|
||||
) : (
|
||||
perms.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium bg-white/60 dark:bg-white/10 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{PERMISSION_LABELS[p] ?? p}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(config)}
|
||||
className="flex-shrink-0 ml-4 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permission Matrix Overview */}
|
||||
{configs.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
|
||||
Permission Matrix <InfoTooltip content="Overview of which permissions each role has by default." />
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">Permission</th>
|
||||
{configs.map((c) => (
|
||||
<th key={c.role} className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<tr key={key} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="px-3 py-1.5 text-gray-700 dark:text-gray-300 font-medium sticky left-0 bg-white dark:bg-gray-900">
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</td>
|
||||
{configs.map((c) => {
|
||||
const perms = c.defaultPermissions as string[];
|
||||
const has = perms.includes(key);
|
||||
return (
|
||||
<td key={c.role} className="px-3 py-1.5 text-center">
|
||||
{has ? (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full text-gray-300 dark:text-gray-600">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingRole && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Configure Role
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{editingRole.role}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
|
||||
{actionError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRole.label}
|
||||
onChange={(e) => setEditingRole({ ...editingRole, label: e.target.value })}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRole.description}
|
||||
onChange={(e) => setEditingRole({ ...editingRole, description: e.target.value })}
|
||||
placeholder="Brief description of this role..."
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Badge Color
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{COLOR_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setEditingRole({ ...editingRole, color: opt.value })}
|
||||
className={`w-7 h-7 rounded-full ${opt.class} transition-all ${
|
||||
editingRole.color === opt.value
|
||||
? "ring-2 ring-offset-2 ring-brand-500 dark:ring-offset-gray-900 scale-110"
|
||||
: "opacity-60 hover:opacity-100"
|
||||
}`}
|
||||
title={opt.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length})
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="text-[11px] text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectNone}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ALL_PERMISSION_KEYS.map((key) => {
|
||||
const isActive = editingRole.permissions.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => togglePermission(key)}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
|
||||
isActive
|
||||
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
||||
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isActive
|
||||
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}>
|
||||
{isActive && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={isActive ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</span>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
|
||||
{PERMISSION_DESCRIPTIONS[key] ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingRole(null); setActionError(null); }}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={updateMutation.isPending || !editingRole.label.trim()}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLASS = "app-input";
|
||||
@@ -90,6 +91,11 @@ export function SystemSettingsClient() {
|
||||
const [scoreSaved, setScoreSaved] = useState(false);
|
||||
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
|
||||
|
||||
// DALL-E settings
|
||||
const [dalleDeployment, setDalleDeployment] = useState("");
|
||||
const [dalleEndpoint, setDalleEndpoint] = useState("");
|
||||
const [dalleApiKey, setDalleApiKey] = useState("");
|
||||
|
||||
// SMTP settings
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState(587);
|
||||
@@ -112,6 +118,10 @@ export function SystemSettingsClient() {
|
||||
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
|
||||
const [vacationSaved, setVacationSaved] = useState(false);
|
||||
|
||||
// Timeline
|
||||
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
|
||||
const [timelineSaved, setTimelineSaved] = useState(false);
|
||||
|
||||
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
|
||||
staleTime: 0,
|
||||
});
|
||||
@@ -131,6 +141,9 @@ export function SystemSettingsClient() {
|
||||
if (settings.scoreVisibleRoles) {
|
||||
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
|
||||
}
|
||||
// DALL-E
|
||||
setDalleDeployment(settings.azureDalleDeployment ?? "");
|
||||
setDalleEndpoint(settings.azureDalleEndpoint ?? "");
|
||||
// SMTP
|
||||
setSmtpHost(settings.smtpHost ?? "");
|
||||
setSmtpPort(settings.smtpPort ?? 587);
|
||||
@@ -143,6 +156,8 @@ export function SystemSettingsClient() {
|
||||
setAnonymizationSeed("");
|
||||
// Vacation
|
||||
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
|
||||
// Timeline
|
||||
setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -218,6 +233,13 @@ export function SystemSettingsClient() {
|
||||
},
|
||||
});
|
||||
|
||||
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setTimelineSaved(true);
|
||||
setTimeout(() => setTimelineSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSaveSmtp() {
|
||||
saveSmtpMutation.mutate({
|
||||
smtpHost: smtpHost || undefined,
|
||||
@@ -233,6 +255,10 @@ export function SystemSettingsClient() {
|
||||
saveVacationMutation.mutate({ vacationDefaultDays });
|
||||
}
|
||||
|
||||
function handleSaveTimeline() {
|
||||
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
|
||||
}
|
||||
|
||||
function handleSaveAnonymization() {
|
||||
saveAnonymizationMutation.mutate({
|
||||
anonymizationEnabled,
|
||||
@@ -269,13 +295,16 @@ export function SystemSettingsClient() {
|
||||
aiTemperature: temperature,
|
||||
aiSummaryPrompt: summaryPrompt || undefined,
|
||||
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
|
||||
azureDalleDeployment: dalleDeployment,
|
||||
azureDalleEndpoint: provider === "azure" && dalleEndpoint ? dalleEndpoint : undefined,
|
||||
...(dalleApiKey ? { azureDalleApiKey: dalleApiKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app-page animate-pulse">
|
||||
<div className="h-8 w-48 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="app-page">
|
||||
<div className="h-8 w-48 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -293,8 +322,8 @@ export function SystemSettingsClient() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
AI Provider
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200 flex items-center">
|
||||
AI Provider <InfoTooltip content="Configure the AI service used for generating resource skill profile summaries. Either OpenAI directly or Azure OpenAI Service." />
|
||||
</h2>
|
||||
|
||||
{/* Provider toggle */}
|
||||
@@ -522,8 +551,8 @@ export function SystemSettingsClient() {
|
||||
|
||||
{/* Generation settings */}
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
Generation Settings
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200 flex items-center">
|
||||
Generation Settings <InfoTooltip content="Fine-tune how the AI generates skill profile summaries. These settings affect output length, creativity, and the prompt template." />
|
||||
</h2>
|
||||
|
||||
{/* Max completion tokens */}
|
||||
@@ -989,11 +1018,76 @@ export function SystemSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── DALL-E Image Generation ────────────────────────────────── */}
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
DALL-E Image Generation <InfoTooltip content="Configure the DALL-E model used for generating project cover art. Uses the same provider (OpenAI / Azure) as the chat model above." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Used to generate AI cover art for projects. Leave blank to disable AI cover generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
Deployment Name <InfoTooltip content="The DALL-E model deployment name (e.g. dall-e-3). For OpenAI this is the model name, for Azure it is the deployment name." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={dalleDeployment}
|
||||
onChange={(e) => setDalleDeployment(e.target.value)}
|
||||
placeholder="dall-e-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "azure" && (
|
||||
<>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
Endpoint <InfoTooltip content="Azure endpoint for the DALL-E deployment. Leave empty to use the same endpoint as the chat model." />
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={dalleEndpoint}
|
||||
onChange={(e) => setDalleEndpoint(e.target.value)}
|
||||
placeholder="Leave empty to use same endpoint as chat"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
<span className="flex items-center">
|
||||
API Key{" "}
|
||||
<InfoTooltip content="API key for the DALL-E endpoint. Leave empty to use the same API key as the chat model." />
|
||||
<span className="ml-1 text-xs font-normal text-gray-400">(optional)</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLASS}
|
||||
value={dalleApiKey}
|
||||
onChange={(e) => setDalleApiKey(e.target.value)}
|
||||
placeholder="Leave empty to use same API key as chat"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SMTP / Email ──────────────────────────────────────────── */}
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Email Notifications (SMTP)
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
Email Notifications (SMTP) <InfoTooltip content="Configure SMTP to send email notifications for vacation approvals/rejections. Without SMTP, only in-app notifications are sent." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Used to send email notifications when vacation requests are approved or rejected.
|
||||
@@ -1002,7 +1096,7 @@ export function SystemSettingsClient() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Host</label>
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">SMTP Host <InfoTooltip content="The SMTP server hostname (e.g. smtp.gmail.com, smtp.office365.com)." /></span></label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
@@ -1012,7 +1106,7 @@ export function SystemSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Port</label>
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">SMTP Port <InfoTooltip content="Common ports: 587 (STARTTLS), 465 (SSL/TLS), 25 (unencrypted). Use 587 for most providers." /></span></label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
@@ -1023,7 +1117,7 @@ export function SystemSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Username</label>
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">SMTP Username <InfoTooltip content="Authentication username for the SMTP server. Often the same as the email address." /></span></label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
@@ -1034,8 +1128,8 @@ export function SystemSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
SMTP Password{" "}
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">
|
||||
SMTP Password <InfoTooltip content="The SMTP authentication password. Stored encrypted. Leave blank to keep the existing password." />{" "}</span>
|
||||
{settings?.hasSmtpPassword && (
|
||||
<span className="text-gray-400 font-normal text-xs">
|
||||
(set — leave blank to keep)
|
||||
@@ -1052,7 +1146,7 @@ export function SystemSettingsClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>From Address</label>
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">From Address <InfoTooltip content="The sender email address shown in notification emails (e.g. noreply@planarchy.app)." /></span></label>
|
||||
<input
|
||||
type="email"
|
||||
className={INPUT_CLASS}
|
||||
@@ -1111,8 +1205,8 @@ export function SystemSettingsClient() {
|
||||
{/* ── Vacation Defaults ─────────────────────────────────────── */}
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Vacation Defaults
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
Vacation Defaults <InfoTooltip content="Sets the default vacation entitlement applied when creating new resources or using the bulk-set tool in Vacation Management." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Default annual leave entitlement for new resources and the entitlement bulk-set tool.
|
||||
@@ -1120,7 +1214,7 @@ export function SystemSettingsClient() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
|
||||
<label className={LABEL_CLASS}><span className="flex items-center">Default Annual Leave Days <InfoTooltip content="The number of vacation days granted per year. In Germany, the legal minimum is 20 days; 28-30 is common. This value is used when creating new entitlement records." /></span></label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
@@ -1151,8 +1245,48 @@ export function SystemSettingsClient() {
|
||||
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
Viewer Anonymization
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
Timeline <InfoTooltip content="Settings for the timeline view, including undo history depth." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure timeline behavior and undo/redo history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className={LABEL_CLASS}>Undo History Depth</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={undoMaxSteps}
|
||||
onChange={(e) => setUndoMaxSteps(parseInt(e.target.value, 10) || 50)}
|
||||
min={1}
|
||||
max={200}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Maximum number of undo steps for timeline operations (single moves and batch shifts). Default: 50.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveTimeline}
|
||||
disabled={saveTimelineMutation.isPending}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{saveTimelineMutation.isPending ? "Saving…" : "Save Timeline Settings"}
|
||||
</button>
|
||||
{timelineSaved && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={PANEL_CLASS}>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
Viewer Anonymization <InfoTooltip content="When enabled, all resource names, EIDs, and emails are replaced with stable fictional aliases (e.g. superhero names) in the UI. Real data stays in the database. Useful for demos and screenshots." />
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Global debug mode that keeps real identities in the database but replaces displayed
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
|
||||
import { useState, useMemo } from "react";
|
||||
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
@@ -55,6 +56,9 @@ type UserRow = {
|
||||
email: string;
|
||||
systemRole: string;
|
||||
createdAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
lastActiveAt: Date | null;
|
||||
permissionOverrides: PermissionOverrides | null;
|
||||
};
|
||||
|
||||
type EditState = {
|
||||
@@ -93,6 +97,25 @@ export function UsersClient() {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: roleConfigs } = trpc.systemRoleConfig.list.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Build dynamic role defaults map from DB config (fallback to hardcoded)
|
||||
const roleDefaultsMap = useMemo(() => {
|
||||
if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS;
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const c of roleConfigs) {
|
||||
map[c.role] = c.defaultPermissions as string[];
|
||||
}
|
||||
return map as Record<SystemRole, string[]>;
|
||||
}, [roleConfigs]);
|
||||
|
||||
const { data: activeData } = trpc.user.activeCount.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
|
||||
{ userId: selectedUserId ?? "" },
|
||||
{ enabled: !!selectedUserId },
|
||||
@@ -145,13 +168,14 @@ export function UsersClient() {
|
||||
|
||||
function openEdit(user: UserRow) {
|
||||
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
||||
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
||||
setSelectedUserId(user.id);
|
||||
setEditState({
|
||||
userId: user.id,
|
||||
systemRole: role,
|
||||
granted: new Set(),
|
||||
denied: new Set(),
|
||||
chapterIds: "",
|
||||
granted: new Set(overrides?.granted ?? []),
|
||||
denied: new Set(overrides?.denied ?? []),
|
||||
chapterIds: (overrides?.chapterIds ?? []).join(", "),
|
||||
});
|
||||
setActionError(null);
|
||||
}
|
||||
@@ -279,6 +303,21 @@ export function UsersClient() {
|
||||
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
|
||||
];
|
||||
|
||||
function isOnline(user: UserRow) {
|
||||
if (!user.lastActiveAt) return false;
|
||||
return Date.now() - new Date(user.lastActiveAt).getTime() < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | null) {
|
||||
if (!date) return "Never";
|
||||
const d = new Date(date);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60_000) return "Just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -288,7 +327,18 @@ export function UsersClient() {
|
||||
Manage user roles and permission overrides
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{activeData && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-3 py-2 text-sm">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||
</span>
|
||||
<span className="font-medium text-green-700 dark:text-green-400">
|
||||
{activeData.count} online
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
|
||||
@@ -362,9 +412,11 @@ export function UsersClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} />
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
|
||||
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
|
||||
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
|
||||
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th>
|
||||
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." />
|
||||
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
@@ -372,14 +424,14 @@ export function UsersClient() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-400">
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-400">
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -402,6 +454,22 @@ export function UsersClient() {
|
||||
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isOnline(user) ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{formatRelativeTime(user.lastLoginAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
@@ -445,8 +513,8 @@ export function UsersClient() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name <InfoTooltip content="The display name for this user account." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -458,8 +526,8 @@ export function UsersClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email <InfoTooltip content="Login email address. Also used to auto-link the user to a resource record." />
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -471,8 +539,8 @@ export function UsersClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password <InfoTooltip content="Minimum 8 characters. Stored securely using Argon2 hashing." />
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -484,8 +552,8 @@ export function UsersClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Role
|
||||
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Role <InfoTooltip content="ADMIN: full system access. MANAGER: manage resources, projects, allocations. CONTROLLER: read + export financial data. USER: standard access. VIEWER: read-only." />
|
||||
</label>
|
||||
<select
|
||||
value={createState.systemRole}
|
||||
@@ -547,8 +615,8 @@ export function UsersClient() {
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
|
||||
{/* System Role */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
System Role
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
System Role <InfoTooltip content="The base role determines default permissions. Change the role and click 'Save Role' to apply. Permission overrides below can further customize access." />
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
@@ -575,89 +643,114 @@ export function UsersClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Effective Permissions */}
|
||||
{effectivePerms && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Effective Permissions
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => {
|
||||
const isActive = effectivePerms.effectivePermissions.includes(key);
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
isActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
|
||||
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through"
|
||||
}`}
|
||||
>
|
||||
{/* Permissions */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
|
||||
Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
|
||||
</h3>
|
||||
<div className="flex gap-1.5 mb-3 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">×</span></span> Denied
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{ALL_PERMISSION_KEYS.map((key) => {
|
||||
const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []);
|
||||
const isRoleDefault = roleDefaults.has(key as PermissionKey);
|
||||
const isGranted = editState.granted.has(key);
|
||||
const isDenied = editState.denied.has(key);
|
||||
|
||||
// Determine display state
|
||||
let state: "default" | "granted" | "denied" | "off";
|
||||
if (isDenied) state = "denied";
|
||||
else if (isGranted) state = "granted";
|
||||
else if (isRoleDefault) state = "default";
|
||||
else state = "off";
|
||||
|
||||
function cycleState() {
|
||||
if (!editState) return;
|
||||
const nextGranted = new Set(editState.granted);
|
||||
const nextDenied = new Set(editState.denied);
|
||||
|
||||
if (isRoleDefault) {
|
||||
// Role default: off → denied → off
|
||||
if (isDenied) {
|
||||
nextDenied.delete(key);
|
||||
} else {
|
||||
nextDenied.add(key);
|
||||
nextGranted.delete(key);
|
||||
}
|
||||
} else {
|
||||
// Non-default: off → granted → off
|
||||
if (isGranted) {
|
||||
nextGranted.delete(key);
|
||||
} else {
|
||||
nextGranted.add(key);
|
||||
nextDenied.delete(key);
|
||||
}
|
||||
}
|
||||
setEditState({ ...editState, granted: nextGranted, denied: nextDenied });
|
||||
}
|
||||
|
||||
const stateStyles = {
|
||||
default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
|
||||
granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
|
||||
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
|
||||
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
|
||||
};
|
||||
|
||||
const checkStyles = {
|
||||
default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40",
|
||||
granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40",
|
||||
denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40",
|
||||
off: "text-gray-400 border-gray-300 dark:border-gray-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={cycleState}
|
||||
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
|
||||
>
|
||||
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
|
||||
{state === "default" && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
|
||||
)}
|
||||
{state === "granted" && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
|
||||
)}
|
||||
{state === "denied" && (
|
||||
<span className="text-xs font-bold leading-none">×</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Permission Overrides */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Permission Overrides
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Additional Grants */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2 uppercase tracking-wide">
|
||||
Additional Grants
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={`grant-${key}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editState.granted.has(key)}
|
||||
onChange={() => toggleGranted(key)}
|
||||
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit Denials */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-red-700 dark:text-red-400 mb-2 uppercase tracking-wide">
|
||||
Explicit Denials
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ALL_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={`deny-${key}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editState.denied.has(key)}
|
||||
onChange={() => toggleDenied(key)}
|
||||
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
{PERMISSION_LABELS[key] ?? key}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{state === "default" && (
|
||||
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
|
||||
)}
|
||||
{state === "granted" && (
|
||||
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
|
||||
)}
|
||||
{state === "denied" && (
|
||||
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Chapter Scope */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
||||
Chapter Scope (comma-separated IDs, leave blank for all)
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
||||
Chapter Scope (comma-separated IDs, leave blank for all) <InfoTooltip content="Restrict this user's access to specific chapters/disciplines only. Leave blank to allow access to all chapters." />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type CategoryRow = {
|
||||
@@ -122,11 +123,11 @@ export function UtilizationCategoriesClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Code</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Default</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Order</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Code <InfoTooltip content="A short unique identifier (e.g. 'Chg', 'Int', 'OH'). Used in reports and exports." /></span></th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Name <InfoTooltip content="The display name for this category (e.g. 'Chargeable', 'Internal', 'Overhead'). Shown in dropdowns and reports." /></span></th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center">Description <InfoTooltip content="Explains what type of work falls into this category. Helps users choose the right category for projects." /></span></th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Default <InfoTooltip content="The default category pre-selected when creating new projects. Only one category can be default." /></span></th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider"><span className="flex items-center justify-center">Order <InfoTooltip content="Controls the display order in dropdowns and reports. Lower numbers appear first." /></span></th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -171,7 +172,7 @@ export function UtilizationCategoriesClient() {
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="A short unique identifier for this category. Used in reports and data exports." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
@@ -181,7 +182,7 @@ export function UtilizationCategoriesClient() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls display position. Lower numbers appear first." /></label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
@@ -192,7 +193,7 @@ export function UtilizationCategoriesClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The display name shown in project dropdowns and chargeability reports." /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
@@ -203,7 +204,7 @@ export function UtilizationCategoriesClient() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description <InfoTooltip content="Explains what type of work this category covers. Helps project managers choose the correct category." /></label>
|
||||
<textarea
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
@@ -220,7 +221,7 @@ export function UtilizationCategoriesClient() {
|
||||
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Default category for new projects
|
||||
Default category for new projects <InfoTooltip content="When checked, new projects are automatically assigned this category." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
"allocation.created",
|
||||
"allocation.updated",
|
||||
"allocation.deleted",
|
||||
"project.created",
|
||||
"project.status_changed",
|
||||
"vacation.approved",
|
||||
"estimate.submitted",
|
||||
"estimate.approved",
|
||||
] as const;
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
"allocation.created": "Allocation Created",
|
||||
"allocation.updated": "Allocation Updated",
|
||||
"allocation.deleted": "Allocation Deleted",
|
||||
"project.created": "Project Created",
|
||||
"project.status_changed": "Project Status Changed",
|
||||
"vacation.approved": "Vacation Approved",
|
||||
"estimate.submitted": "Estimate Submitted",
|
||||
"estimate.approved": "Estimate Approved",
|
||||
};
|
||||
|
||||
const INPUT_CLASS = "app-input";
|
||||
const LABEL_CLASS = "app-label";
|
||||
const PRIMARY_BUTTON =
|
||||
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
|
||||
const SECONDARY_BUTTON =
|
||||
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
||||
const DANGER_BUTTON =
|
||||
"rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50";
|
||||
|
||||
interface WebhookFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
events: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: WebhookFormData = {
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
// Show scheme + host, mask the rest
|
||||
if (u.pathname.length > 1) {
|
||||
return `${u.protocol}//${host}/****`;
|
||||
}
|
||||
return `${u.protocol}//${host}`;
|
||||
} catch {
|
||||
return "****";
|
||||
}
|
||||
}
|
||||
|
||||
export function WebhooksClient() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: webhooks, isLoading } = trpc.webhook.list.useQuery();
|
||||
const createMut = trpc.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate();
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
const updateMut = trpc.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate();
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
const deleteMut = trpc.webhook.delete.useMutation({
|
||||
onSuccess: () => void utils.webhook.list.invalidate(),
|
||||
});
|
||||
const testMut = trpc.webhook.test.useMutation();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<WebhookFormData>(emptyForm);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
id: string;
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
} | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(wh: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string | null;
|
||||
events: string[];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
setEditingId(wh.id);
|
||||
setForm({
|
||||
name: wh.name,
|
||||
url: wh.url,
|
||||
secret: wh.secret ?? "",
|
||||
events: wh.events,
|
||||
isActive: wh.isActive,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function toggleEvent(event: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
events: prev.events.includes(event)
|
||||
? prev.events.filter((e) => e !== event)
|
||||
: [...prev.events, event],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (editingId) {
|
||||
updateMut.mutate({
|
||||
id: editingId,
|
||||
data: {
|
||||
name: form.name,
|
||||
url: form.url,
|
||||
...(form.secret ? { secret: form.secret } : { secret: null }),
|
||||
events: form.events,
|
||||
isActive: form.isActive,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
name: form.name,
|
||||
url: form.url,
|
||||
...(form.secret ? { secret: form.secret } : {}),
|
||||
events: form.events,
|
||||
isActive: form.isActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleTest(id: string) {
|
||||
setTestResult(null);
|
||||
testMut.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setTestResult({ id, ...result });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggleActive(id: string, currentActive: boolean) {
|
||||
updateMut.mutate({ id, data: { isActive: !currentActive } });
|
||||
}
|
||||
|
||||
const isSaving = createMut.isPending || updateMut.isPending;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure outbound webhooks to notify external services about events in Planarchy.
|
||||
</p>
|
||||
</div>
|
||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||
Add Webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Webhook List */}
|
||||
{isLoading ? (
|
||||
<div className="app-surface p-8 text-center text-gray-500">Loading...</div>
|
||||
) : !webhooks?.length ? (
|
||||
<div className="app-surface p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No webhooks configured yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className="app-surface flex items-center gap-4 p-4"
|
||||
>
|
||||
{/* Active indicator */}
|
||||
<div
|
||||
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||
wh.isActive ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
title={wh.isActive ? "Active" : "Inactive"}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{wh.name}
|
||||
</span>
|
||||
{wh.url.includes("hooks.slack.com") && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
Slack
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{maskUrl(wh.url)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{wh.events.map((ev) => (
|
||||
<span
|
||||
key={ev}
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Test result */}
|
||||
{testResult && testResult.id === wh.id && (
|
||||
<div
|
||||
className={`mt-1 text-xs ${
|
||||
testResult.success
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
Test: {testResult.statusCode} {testResult.statusText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => handleTest(wh.id)}
|
||||
disabled={testMut.isPending}
|
||||
title="Send test payload"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() =>
|
||||
handleToggleActive(wh.id, wh.isActive)
|
||||
}
|
||||
disabled={updateMut.isPending}
|
||||
>
|
||||
{wh.isActive ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => openEditModal(wh)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirmId === wh.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={DANGER_BUTTON}
|
||||
onClick={() => {
|
||||
deleteMut.mutate({ id: wh.id });
|
||||
setDeleteConfirmId(null);
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(wh.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="app-surface-strong mx-4 w-full max-w-lg space-y-5 rounded-2xl p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingId ? "Edit Webhook" : "Create Webhook"}
|
||||
</h2>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Name</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Slack Notifications"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>URL</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secret */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
Secret (optional)
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
|
||||
placeholder="HMAC signing secret"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Events</label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<label
|
||||
key={ev}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.events.includes(ev)}
|
||||
onChange={() => toggleEvent(ev)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active toggle */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, isActive: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
|
||||
{/* Error display */}
|
||||
{(createMut.error || updateMut.error) && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{createMut.error?.message ?? updateMut.error?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={PRIMARY_BUTTON}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving || !form.name || !form.url || form.events.length === 0}
|
||||
>
|
||||
{isSaving ? "Saving..." : editingId ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { trpc } from "~/lib/trpc/client.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { RecurrenceEditor } from "./RecurrenceEditor.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
|
||||
type EntryKind = "demand" | "assignment";
|
||||
@@ -22,7 +23,10 @@ interface AllocationModalProps {
|
||||
function toDateInputValue(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toISOString().split("T")[0] ?? "";
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||
@@ -308,7 +312,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{!isDemandEntry && (
|
||||
<div>
|
||||
<label htmlFor="modal-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span>
|
||||
Resource <span className="text-red-500">*</span><InfoTooltip content="The person to assign. Their LCR determines the daily cost of this allocation." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-resource"
|
||||
@@ -330,7 +334,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label htmlFor="modal-project" className={labelClass}>
|
||||
Project <span className="text-red-500">*</span>
|
||||
Project <span className="text-red-500">*</span><InfoTooltip content="The project this time block is allocated to. Costs roll up to the project budget." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-project"
|
||||
@@ -350,7 +354,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role</label>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role<InfoTooltip content="Role for this allocation. Pick a predefined role or type a custom one." /></label>
|
||||
<select
|
||||
id="modal-role"
|
||||
value={roleId}
|
||||
@@ -380,7 +384,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
@@ -392,7 +396,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <span className="text-red-500">*</span>
|
||||
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of this allocation period (inclusive)." />
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
@@ -409,7 +413,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-hours" className={labelClass}>
|
||||
Hours / Day
|
||||
Hours / Day<InfoTooltip content="Working hours per day. Total cost = LCR x hours/day x working days. Vacation days are excluded." />
|
||||
</label>
|
||||
<input
|
||||
id="modal-hours"
|
||||
@@ -424,7 +428,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" className={labelClass}>
|
||||
Status
|
||||
Status<InfoTooltip content="PROPOSED = draft/request · CONFIRMED = approved · ACTIVE = in progress · COMPLETED = done · CANCELLED = removed." />
|
||||
</label>
|
||||
<select
|
||||
id="modal-status"
|
||||
@@ -453,7 +457,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span><InfoTooltip content="Enable to repeat this allocation on specific days (e.g. every Monday/Wednesday). Hours per day applies on active days only." />
|
||||
</label>
|
||||
{isRecurring && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -21,6 +21,16 @@ import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||
|
||||
/** Left-border color by allocation status for instant visual scanning */
|
||||
const STATUS_LEFT_BORDER: Record<string, string> = {
|
||||
ACTIVE: "border-l-green-500",
|
||||
PROPOSED: "border-l-amber-500",
|
||||
CONFIRMED: "border-l-blue-500",
|
||||
COMPLETED: "border-l-gray-400",
|
||||
CANCELLED: "border-l-red-500",
|
||||
};
|
||||
|
||||
/** Fragment wrapper for grouped rows — avoids unnecessary DOM nodes */
|
||||
function GroupRows({ children }: { children: React.ReactNode }) {
|
||||
@@ -54,6 +64,7 @@ export function AllocationsClient() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ single?: AllocationWithDetails; ids?: string[] } | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
const [showStatusToast, setShowStatusToast] = useState(false);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -104,6 +115,7 @@ export function AllocationsClient() {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
selection.clear();
|
||||
setShowStatusToast(true);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -361,10 +373,11 @@ export function AllocationsClient() {
|
||||
// colSpan for empty/loading states: checkbox + visible columns + actions
|
||||
const totalColSpan = 1 + visibleColumns.length + 1;
|
||||
|
||||
function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false) {
|
||||
function renderAllocRow(alloc: AllocationWithDetails, isGrouped = false, rowIndex = 0) {
|
||||
const isSelected = selection.selectedIds.has(alloc.id);
|
||||
const leftBorder = STATUS_LEFT_BORDER[alloc.status] ?? "border-l-gray-300";
|
||||
return (
|
||||
<tr key={alloc.id} className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
|
||||
<tr key={alloc.id} className={`border-l-[3px] transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 animate-row-enter ${leftBorder} ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`} style={{ animationDelay: `${Math.min(rowIndex * 15, 300)}ms` }}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -428,6 +441,7 @@ export function AllocationsClient() {
|
||||
|
||||
return (
|
||||
<div className="app-page space-y-5 pb-24">
|
||||
<SuccessToast show={showStatusToast} message="Allocation status updated" onDone={() => setShowStatusToast(false)} />
|
||||
<div className="app-page-header gap-4">
|
||||
<div>
|
||||
<h1 className="app-page-title">Allocations</h1>
|
||||
@@ -586,9 +600,12 @@ export function AllocationsClient() {
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
const tooltips: Record<string, { tip: string; width?: string }> = {
|
||||
resource: { tip: "The person assigned to this time block. Grouped view clusters entries by resource." },
|
||||
project: { tip: "The project this allocation belongs to, identified by short code and name." },
|
||||
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
|
||||
dates: { tip: "Start and end date of this allocation period (inclusive)." },
|
||||
hoursPerDay: { tip: "Planned working hours per calendar day for this allocation." },
|
||||
cost: { tip: "Resource LCR × hours per day. Reflects the cost of one day of work for this allocation." },
|
||||
cost: { tip: "Daily cost = resource LCR x hours per day. Total cost = daily cost x working days.", width: "w-72" },
|
||||
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
|
||||
};
|
||||
const t = tooltips[col.key];
|
||||
@@ -623,7 +640,7 @@ export function AllocationsClient() {
|
||||
)}
|
||||
|
||||
{!isLoading && viewMode === "flat" &&
|
||||
sorted.map((alloc) => renderAllocRow(alloc))}
|
||||
sorted.map((alloc, index) => renderAllocRow(alloc, false, index))}
|
||||
|
||||
{!isLoading && viewMode === "grouped" &&
|
||||
groups.map((group) => {
|
||||
@@ -682,7 +699,7 @@ export function AllocationsClient() {
|
||||
|
||||
// Single allocation for this project — render directly, no sub-group header
|
||||
if (subGroup.allocations.length === 1) {
|
||||
return <GroupRows key={subKey}>{renderAllocRow(subGroup.allocations[0]!, true)}</GroupRows>;
|
||||
return <GroupRows key={subKey}>{renderAllocRow(subGroup.allocations[0]!, true, 0)}</GroupRows>;
|
||||
}
|
||||
|
||||
// Multiple allocations — show collapsible project sub-group
|
||||
@@ -729,7 +746,7 @@ export function AllocationsClient() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{isSubExpanded && subGroup.allocations.map((alloc) => renderAllocRow(alloc, true))}
|
||||
{isSubExpanded && subGroup.allocations.map((alloc, idx) => renderAllocRow(alloc, true, idx))}
|
||||
</GroupRows>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useMemo, useCallback } from "react";
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { formatDateMedium } from "~/lib/format.js";
|
||||
import { formatCents, formatDateMedium } from "~/lib/format.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
interface OpenDemandAllocation {
|
||||
id: string;
|
||||
@@ -68,15 +70,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200);
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidatePlanningViews = useCallback(async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
await utils.timeline.getEntries.invalidate();
|
||||
await utils.timeline.getEntriesView.invalidate();
|
||||
await utils.timeline.getProjectContext.invalidate();
|
||||
await utils.timeline.getBudgetStatus.invalidate();
|
||||
}, [utils]);
|
||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, search: debouncedSearch || undefined, limit: 50 },
|
||||
@@ -192,8 +186,9 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||
{phase === "plan" ? "Plan Demand Assignment" : "Confirm Assignments"}
|
||||
<InfoTooltip content="Fill an open demand by assigning one or more real resources to a placeholder staffing requirement. Each assignment creates a new allocation." />
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} disabled={submitting} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none disabled:opacity-30">×</button>
|
||||
</div>
|
||||
@@ -209,7 +204,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total
|
||||
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR` : ""}
|
||||
{allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,8 +394,8 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">Resource</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">h/day</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Hours</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Est. Cost</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400">Coverage</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Est. Cost<InfoTooltip content="Estimated cost = resource LCR x available hours in the demand period." /></span></th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400"><span className="inline-flex items-center justify-end gap-0.5">Coverage<InfoTooltip content="Percentage of the demand period this resource can cover, accounting for existing bookings." /></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
@@ -412,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-medium ${r.coveragePercent >= 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}>
|
||||
{r.coveragePercent}%
|
||||
@@ -429,7 +424,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
{Math.round(consumedHours)}h / {totalDemandHours}h
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR
|
||||
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
|
||||
@@ -439,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR
|
||||
{formatCents(allocation.budgetCents)} EUR
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs">
|
||||
{(() => {
|
||||
@@ -447,7 +442,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
const remain = allocation.budgetCents! - totalCost;
|
||||
return (
|
||||
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
|
||||
{remain < 0 ? `${(Math.abs(remain) / 100).toLocaleString("de-DE")} over` : `${(remain / 100).toLocaleString("de-DE")} left`}
|
||||
{remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
@@ -38,7 +39,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
<div className="space-y-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Frequency selector */}
|
||||
<div>
|
||||
<span className={labelClass}>Frequency</span>
|
||||
<span className={labelClass}>Frequency<InfoTooltip content="How often the allocation repeats: weekly, biweekly, monthly, or a custom pattern." /></span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.values(RecurrenceFrequency).map((f) => (
|
||||
<button
|
||||
@@ -66,7 +67,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||
<div>
|
||||
<span className={labelClass}>Days of week</span>
|
||||
<span className={labelClass}>Days of week<InfoTooltip content="Select which days of the week this allocation is active. Hours per day applies only on selected days." /></span>
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAY_LABELS.map((label, dow) => {
|
||||
const selected = (value?.weekdays ?? []).includes(dow);
|
||||
@@ -138,7 +139,7 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per recurring day (optional override)</label>
|
||||
<label className={labelClass}>Hours per recurring day (optional override)<InfoTooltip content="Override the allocation's default hours for recurring days only. Leave empty to use the allocation's hours/day." /></label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useEffect, useState } from "react";
|
||||
import { DOMAIN_COLORS, DOMAIN_LABELS, type Domain } from "~/components/analytics/computation-graph/domain-colors";
|
||||
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
|
||||
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
|
||||
|
||||
// ─── Layout constants ────────────────────────────────────────────────────────
|
||||
|
||||
const NODE_W = 220;
|
||||
const NODE_H = 88;
|
||||
const H_GAP = 50;
|
||||
const V_GAP = 140;
|
||||
const PADDING = 60;
|
||||
|
||||
const MIN_ZOOM = 0.2;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.12;
|
||||
|
||||
// ─── 2D DAG layout ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PlacedNode extends PositionedNode {
|
||||
px: number;
|
||||
py: number;
|
||||
}
|
||||
|
||||
function build2DLayout(nodes: PositionedNode[]): { placed: PlacedNode[]; width: number; height: number } {
|
||||
if (nodes.length === 0) return { placed: [], width: 800, height: 400 };
|
||||
|
||||
const byLevel = new Map<number, PositionedNode[]>();
|
||||
for (const n of nodes) {
|
||||
const arr = byLevel.get(n.level) ?? [];
|
||||
arr.push(n);
|
||||
byLevel.set(n.level, arr);
|
||||
}
|
||||
|
||||
const levels = [...byLevel.keys()].sort((a, b) => a - b);
|
||||
|
||||
const domainOrder: Domain[] = [
|
||||
"EFFORT", "SAH", "ESTIMATE", "INPUT", "ALLOCATION", "COMMERCIAL",
|
||||
"RULES", "EXPERIENCE", "CHARGEABILITY", "SPREAD", "BUDGET",
|
||||
];
|
||||
for (const [, arr] of byLevel) {
|
||||
arr.sort((a, b) => {
|
||||
const ai = domainOrder.indexOf(a.domain as Domain);
|
||||
const bi = domainOrder.indexOf(b.domain as Domain);
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
let maxRowNodes = 0;
|
||||
for (const arr of byLevel.values()) {
|
||||
if (arr.length > maxRowNodes) maxRowNodes = arr.length;
|
||||
}
|
||||
|
||||
const svgW = Math.max(800, maxRowNodes * (NODE_W + H_GAP) - H_GAP + PADDING * 2);
|
||||
const svgH = levels.length * (NODE_H + V_GAP) - V_GAP + PADDING * 2;
|
||||
|
||||
const placed: PlacedNode[] = [];
|
||||
for (let li = 0; li < levels.length; li++) {
|
||||
const level = levels[li]!;
|
||||
const row = byLevel.get(level)!;
|
||||
const rowWidth = row.length * (NODE_W + H_GAP) - H_GAP;
|
||||
const offsetX = (svgW - rowWidth) / 2;
|
||||
|
||||
for (let ni = 0; ni < row.length; ni++) {
|
||||
placed.push({
|
||||
...row[ni]!,
|
||||
px: offsetX + ni * (NODE_W + H_GAP) + NODE_W / 2,
|
||||
py: PADDING + li * (NODE_H + V_GAP) + NODE_H / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { placed, width: svgW, height: svgH };
|
||||
}
|
||||
|
||||
// ─── Edge path ───────────────────────────────────────────────────────────────
|
||||
|
||||
function edgePath(src: PlacedNode, tgt: PlacedNode): string {
|
||||
const sx = src.px;
|
||||
const sy = src.py + NODE_H / 2;
|
||||
const ex = tgt.px;
|
||||
const ey = tgt.py - NODE_H / 2;
|
||||
const midY = (sy + ey) / 2;
|
||||
return `M ${sx} ${sy} C ${sx} ${midY}, ${ex} ${midY}, ${ex} ${ey}`;
|
||||
}
|
||||
|
||||
// ─── Pre-computed edge label data ────────────────────────────────────────────
|
||||
|
||||
interface EdgeLabel {
|
||||
x: number;
|
||||
y: number;
|
||||
anchor: "start" | "middle" | "end";
|
||||
offsetX: number;
|
||||
text: string;
|
||||
pillW: number;
|
||||
pillX: number;
|
||||
}
|
||||
|
||||
function computeEdgeLabel(src: PlacedNode, tgt: PlacedNode, formula: string): EdgeLabel {
|
||||
const labelX = (src.px + tgt.px) / 2;
|
||||
const labelY = (src.py + NODE_H / 2 + tgt.py - NODE_H / 2) / 2;
|
||||
const offsetX = src.px === tgt.px ? 14 : 0;
|
||||
const anchor = src.px === tgt.px ? "start" : "middle";
|
||||
const text = formula.length > 28 ? formula.slice(0, 26) + "..." : formula;
|
||||
const pillW = text.length * 7 + 12;
|
||||
const pillX = anchor === "middle" ? labelX - pillW / 2 + offsetX : labelX + offsetX - 4;
|
||||
return { x: labelX, y: labelY, anchor, offsetX, text, pillW, pillX };
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
state: ComputationGraphState;
|
||||
}
|
||||
|
||||
export default function ComputationGraph2D({ state }: Props) {
|
||||
const { graphData, highlightedNodes, handleNodeClick } = state;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const transformRef = useRef<HTMLDivElement>(null);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
// Pan & zoom via refs — zero React re-renders during interaction
|
||||
const viewState = useRef({ zoom: 1, panX: 0, panY: 0 });
|
||||
const zoomLabelRef = useRef<HTMLSpanElement>(null);
|
||||
const isPanning = useRef(false);
|
||||
const panStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
const mousePosRef = useRef({ x: 0, y: 0 });
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const hoveredNodeRef = useRef<PositionedNode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
|
||||
const handler = () => setIsDark(mq.matches || document.documentElement.classList.contains("dark"));
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const { placed, width, height } = useMemo(
|
||||
() => build2DLayout(graphData.nodes),
|
||||
[graphData.nodes],
|
||||
);
|
||||
|
||||
const nodeMap = useMemo(() => {
|
||||
const m = new Map<string, PlacedNode>();
|
||||
for (const n of placed) m.set(n.id, n);
|
||||
return m;
|
||||
}, [placed]);
|
||||
|
||||
// ── Apply CSS transform to the wrapper div (GPU-composited, no SVG repaint) ──
|
||||
const applyTransform = useCallback(() => {
|
||||
const el = transformRef.current;
|
||||
if (!el) return;
|
||||
const { zoom, panX, panY } = viewState.current;
|
||||
el.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||||
}, []);
|
||||
|
||||
const updateZoomLabel = useCallback((zoom: number) => {
|
||||
const el = zoomLabelRef.current;
|
||||
if (el) el.textContent = `${Math.round(zoom * 100)}%`;
|
||||
}, []);
|
||||
|
||||
// Update tooltip content + position via DOM (no React re-render)
|
||||
const updateTooltip = useCallback((node: PositionedNode | null) => {
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
if (!node) {
|
||||
el.style.display = "none";
|
||||
return;
|
||||
}
|
||||
el.style.display = "block";
|
||||
el.style.top = `${mousePosRef.current.y + 16}px`;
|
||||
el.style.left = `${mousePosRef.current.x + 16}px`;
|
||||
const label = el.querySelector("[data-tt-label]");
|
||||
const dot = el.querySelector("[data-tt-dot]") as HTMLElement | null;
|
||||
const domain = el.querySelector("[data-tt-domain]");
|
||||
const value = el.querySelector("[data-tt-value]");
|
||||
const unit = el.querySelector("[data-tt-unit]");
|
||||
const desc = el.querySelector("[data-tt-desc]");
|
||||
const formula = el.querySelector("[data-tt-formula]");
|
||||
if (label) label.textContent = node.label;
|
||||
if (dot) dot.style.backgroundColor = node.color;
|
||||
if (domain) domain.textContent = DOMAIN_LABELS[node.domain];
|
||||
if (value) value.textContent = String(node.value);
|
||||
if (unit) unit.textContent = node.unit;
|
||||
if (desc) desc.textContent = node.description;
|
||||
if (formula) {
|
||||
formula.textContent = node.formula ?? "";
|
||||
(formula as HTMLElement).style.display = node.formula ? "block" : "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fit to view
|
||||
const fitToView = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || placed.length === 0) return;
|
||||
const cw = el.clientWidth;
|
||||
const ch = el.clientHeight;
|
||||
const scaleX = cw / width;
|
||||
const scaleY = ch / height;
|
||||
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(scaleX, scaleY) * 0.92));
|
||||
viewState.current = {
|
||||
zoom: newZoom,
|
||||
panX: (cw - width * newZoom) / 2,
|
||||
panY: (ch - height * newZoom) / 2,
|
||||
};
|
||||
applyTransform();
|
||||
updateZoomLabel(newZoom);
|
||||
}, [width, height, placed.length, applyTransform, updateZoomLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (placed.length > 0) fitToView();
|
||||
}, [placed.length, fitToView]);
|
||||
|
||||
// Focus on a node
|
||||
const focusNode = useCallback((node: PlacedNode) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const cw = el.clientWidth;
|
||||
const ch = el.clientHeight;
|
||||
const targetZoom = 1.2;
|
||||
viewState.current = {
|
||||
zoom: targetZoom,
|
||||
panX: cw / 2 - node.px * targetZoom,
|
||||
panY: ch / 2 - node.py * targetZoom,
|
||||
};
|
||||
applyTransform();
|
||||
updateZoomLabel(targetZoom);
|
||||
}, [applyTransform, updateZoomLabel]);
|
||||
|
||||
// Wheel zoom — native event, direct DOM mutation
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const vs = viewState.current;
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
|
||||
const scale = newZoom / vs.zoom;
|
||||
vs.panX = cx - scale * (cx - vs.panX);
|
||||
vs.panY = cy - scale * (cy - vs.panY);
|
||||
vs.zoom = newZoom;
|
||||
applyTransform();
|
||||
updateZoomLabel(newZoom);
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
}, [applyTransform, updateZoomLabel]);
|
||||
|
||||
// Pointer handlers — native events, direct DOM mutation, no React state
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onDown = (e: PointerEvent) => {
|
||||
const tag = (e.target as Element).tagName;
|
||||
const isBg = tag === "svg" || tag === "DIV" || !!(e.target as Element).closest?.("[data-bg]");
|
||||
if (e.button === 1 || isBg) {
|
||||
isPanning.current = true;
|
||||
const vs = viewState.current;
|
||||
panStart.current = { x: e.clientX, y: e.clientY, panX: vs.panX, panY: vs.panY };
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
el.style.cursor = "grabbing";
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
mousePosRef.current.x = e.clientX;
|
||||
mousePosRef.current.y = e.clientY;
|
||||
if (hoveredNodeRef.current) {
|
||||
const tip = tooltipRef.current;
|
||||
if (tip) {
|
||||
tip.style.top = `${e.clientY + 16}px`;
|
||||
tip.style.left = `${e.clientX + 16}px`;
|
||||
}
|
||||
}
|
||||
if (!isPanning.current) return;
|
||||
viewState.current.panX = panStart.current.panX + (e.clientX - panStart.current.x);
|
||||
viewState.current.panY = panStart.current.panY + (e.clientY - panStart.current.y);
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
isPanning.current = false;
|
||||
el.style.cursor = "grab";
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onDown);
|
||||
el.addEventListener("pointermove", onMove);
|
||||
el.addEventListener("pointerup", onUp);
|
||||
el.addEventListener("pointerleave", onUp);
|
||||
return () => {
|
||||
el.removeEventListener("pointerdown", onDown);
|
||||
el.removeEventListener("pointermove", onMove);
|
||||
el.removeEventListener("pointerup", onUp);
|
||||
el.removeEventListener("pointerleave", onUp);
|
||||
};
|
||||
}, [applyTransform, updateTooltip]);
|
||||
|
||||
// Zoom controls
|
||||
const zoomBy = useCallback((direction: 1 | -1) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const cw = el.clientWidth / 2;
|
||||
const ch = el.clientHeight / 2;
|
||||
const vs = viewState.current;
|
||||
const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, vs.zoom * (1 + direction * ZOOM_STEP)));
|
||||
const scale = next / vs.zoom;
|
||||
vs.panX = cw - scale * (cw - vs.panX);
|
||||
vs.panY = ch - scale * (ch - vs.panY);
|
||||
vs.zoom = next;
|
||||
applyTransform();
|
||||
updateZoomLabel(next);
|
||||
}, [applyTransform, updateZoomLabel]);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500">
|
||||
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardBg = isDark ? "#1e293b" : "#ffffff";
|
||||
const cardTextPrimary = isDark ? "#f1f5f9" : "#1e293b";
|
||||
const cardTextSecondary = isDark ? "#94a3b8" : "#64748b";
|
||||
const canvasBg = isDark ? "#0f172a" : "#f8fafc";
|
||||
const gridLine = isDark ? "#1e293b" : "#e2e8f0";
|
||||
const pillBg = isDark ? "#1e293b" : "#ffffff";
|
||||
const pillStroke = isDark ? "#334155" : "#e2e8f0";
|
||||
const pillText = isDark ? "#94a3b8" : "#475569";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
style={{ backgroundColor: canvasBg, cursor: "grab" }}
|
||||
>
|
||||
{/* Wrapper div for CSS transform — GPU-composited, no SVG repaint */}
|
||||
<div
|
||||
ref={transformRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transformOrigin: "0 0",
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<marker id="arrow-default" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill={isDark ? "#475569" : "#94a3b8"} />
|
||||
</marker>
|
||||
<marker id="arrow-highlight" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 Z" fill="#3b82f6" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Grid lines */}
|
||||
{[...new Set(placed.map((n) => n.py))].map((y) => (
|
||||
<line
|
||||
key={`grid-${y}`}
|
||||
x1="0" y1={y} x2={width} y2={y}
|
||||
stroke={gridLine} strokeWidth="1" strokeDasharray="4 4" opacity="0.4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Links ── */}
|
||||
{graphData.links.map((link) => {
|
||||
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
|
||||
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
|
||||
const src = nodeMap.get(sourceId);
|
||||
const tgt = nodeMap.get(targetId);
|
||||
if (!src || !tgt) return null;
|
||||
|
||||
const isHighlighted = highlightedNodes
|
||||
? highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)
|
||||
: false;
|
||||
const isNeutral = !highlightedNodes;
|
||||
const isDimmed = highlightedNodes && !isHighlighted;
|
||||
const showLabel = (isNeutral || isHighlighted) && link.formula;
|
||||
|
||||
return (
|
||||
<g key={`${sourceId}-${targetId}`}>
|
||||
<path
|
||||
d={edgePath(src, tgt)}
|
||||
fill="none"
|
||||
stroke={isDimmed ? (isDark ? "#334155" : "#d1d5db") : src.color}
|
||||
strokeWidth={isDimmed ? 1 : isHighlighted ? (link.weight ?? 1) * 2.5 : (link.weight ?? 1) * 1.5}
|
||||
strokeOpacity={isDimmed ? 0.2 : isHighlighted ? 0.8 : 0.4}
|
||||
markerEnd={isDimmed ? undefined : (isHighlighted ? "url(#arrow-highlight)" : "url(#arrow-default)")}
|
||||
/>
|
||||
{showLabel && (() => {
|
||||
const lbl = computeEdgeLabel(src, tgt, link.formula);
|
||||
return (
|
||||
<>
|
||||
<rect
|
||||
x={lbl.pillX} y={lbl.y - 10}
|
||||
width={lbl.pillW} height={20} rx="4"
|
||||
fill={pillBg} stroke={pillStroke} strokeWidth="1" opacity="0.95"
|
||||
/>
|
||||
<text
|
||||
x={lbl.x + lbl.offsetX} y={lbl.y + 1}
|
||||
fontSize="12" fill={pillText}
|
||||
textAnchor={lbl.anchor} dominantBaseline="central"
|
||||
className="pointer-events-none select-none"
|
||||
fontFamily="ui-monospace, monospace"
|
||||
>
|
||||
{lbl.text}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Nodes ── */}
|
||||
{placed.map((node) => {
|
||||
const isActive = !highlightedNodes || highlightedNodes.has(node.id);
|
||||
const isDimmed = highlightedNodes && !isActive;
|
||||
const color = DOMAIN_COLORS[node.domain as Domain] ?? "#6b7280";
|
||||
|
||||
const valueStr = typeof node.value === "number"
|
||||
? node.value.toFixed(1)
|
||||
: String(node.value);
|
||||
const displayValue = valueStr.length > 16 ? valueStr.slice(0, 14) + "..." : valueStr;
|
||||
const displayLabel = node.label.length > 22 ? node.label.slice(0, 20) + "..." : node.label;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.px - NODE_W / 2}, ${node.py - NODE_H / 2})`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.detail === 2) {
|
||||
focusNode(node);
|
||||
} else {
|
||||
handleNodeClick(node.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => { hoveredNodeRef.current = node; updateTooltip(node); }}
|
||||
onMouseLeave={() => { hoveredNodeRef.current = null; updateTooltip(null); }}
|
||||
className="cursor-pointer"
|
||||
opacity={isDimmed ? 0.2 : 1}
|
||||
>
|
||||
<rect
|
||||
width={NODE_W} height={NODE_H} rx="10"
|
||||
fill={cardBg}
|
||||
stroke={isDark ? "#334155" : "#e2e8f0"}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<path
|
||||
d={`M 10 0 H ${NODE_W - 10} Q ${NODE_W} 0 ${NODE_W} 10 V 4 H 0 V 10 Q 0 0 10 0 Z`}
|
||||
fill={color}
|
||||
/>
|
||||
<text x="12" y="22" fontSize="11" fontWeight="500" fill={cardTextSecondary} fontFamily="system-ui, sans-serif">
|
||||
{DOMAIN_LABELS[node.domain as Domain]}
|
||||
</text>
|
||||
<text x="12" y="44" fontSize="14" fontWeight="600" fill={cardTextPrimary} fontFamily="system-ui, sans-serif">
|
||||
{displayLabel}
|
||||
</text>
|
||||
<text x="12" y="68" fontSize="18" fontWeight="700" fill={color} fontFamily="ui-monospace, monospace">
|
||||
{displayValue}
|
||||
</text>
|
||||
<text x={NODE_W - 12} y="68" fontSize="12" fontWeight="400" fill={cardTextSecondary} textAnchor="end" fontFamily="system-ui, sans-serif">
|
||||
{node.unit}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Background click target for pan (covers viewport) */}
|
||||
<div data-bg="true" className="absolute inset-0" style={{ zIndex: -1 }} />
|
||||
|
||||
{/* ── Zoom controls (bottom-right) ── */}
|
||||
<div className="absolute bottom-4 right-4 flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => zoomBy(1)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
||||
title="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zoomBy(-1)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-bold text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
||||
title="Zoom out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
onClick={fitToView}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-xs font-medium text-zinc-700 shadow-sm hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
||||
title="Fit to view"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="1" y="1" width="12" height="12" rx="2" />
|
||||
<path d="M 4 7 L 1 7 M 10 7 L 13 7 M 7 4 L 7 1 M 7 10 L 7 13" />
|
||||
</svg>
|
||||
</button>
|
||||
<span ref={zoomLabelRef} className="mt-1 text-center text-[10px] text-zinc-400">
|
||||
100%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Tooltip — always rendered, shown/hidden via DOM */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-800 shadow-xl dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200"
|
||||
style={{ display: "none", top: 0, left: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span data-tt-dot className="inline-block h-2.5 w-2.5 rounded-full" />
|
||||
<span data-tt-label className="font-semibold" />
|
||||
<span data-tt-domain className="ml-auto text-xs text-zinc-500" />
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-bold">
|
||||
<span data-tt-value /> <span data-tt-unit className="text-sm font-normal text-zinc-400" />
|
||||
</div>
|
||||
<div data-tt-desc className="mt-1 text-xs text-zinc-500 dark:text-zinc-400" />
|
||||
<div data-tt-formula className="mt-1 font-mono text-xs text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DOMAIN_LABELS } from "~/components/analytics/computation-graph/domain-colors";
|
||||
import { createNodeSprite, createDimmedNodeSprite, getLinkColor } from "~/components/analytics/computation-graph/node-renderer";
|
||||
import type { PositionedNode } from "~/components/analytics/computation-graph/graph-data";
|
||||
import type { ComputationGraphState } from "~/components/analytics/computation-graph/useComputationGraphData";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph3D = dynamic(() => import("react-force-graph-3d"), { ssr: false }) as any;
|
||||
|
||||
interface Props {
|
||||
state: ComputationGraphState;
|
||||
}
|
||||
|
||||
export default function ComputationGraph3DView({ state }: Props) {
|
||||
const { graphData, highlightedNodes, handleNodeClick, setHoveredNode } = state;
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onNodeClick = useCallback((node: any) => {
|
||||
handleNodeClick((node as PositionedNode).id);
|
||||
}, [handleNodeClick]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onNodeHover = useCallback((node: any) => {
|
||||
setHoveredNode(node as PositionedNode | null);
|
||||
}, [setHoveredNode]);
|
||||
|
||||
const nodeThreeObject = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(node: any) => {
|
||||
const n = node as PositionedNode;
|
||||
if (highlightedNodes && !highlightedNodes.has(n.id)) {
|
||||
return createDimmedNodeSprite(n);
|
||||
}
|
||||
return createNodeSprite(n);
|
||||
},
|
||||
[highlightedNodes],
|
||||
);
|
||||
|
||||
const linkColorFn = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(link: any) => {
|
||||
if (!highlightedNodes) return getLinkColor(link, 0.4);
|
||||
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
|
||||
const targetId = typeof link.target === "object" ? link.target.id : link.target;
|
||||
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
|
||||
return getLinkColor(link, 0.9);
|
||||
}
|
||||
return getLinkColor(link, 0.08);
|
||||
},
|
||||
[highlightedNodes],
|
||||
);
|
||||
|
||||
const linkWidthFn = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(link: any) => {
|
||||
if (!highlightedNodes) return link.weight ?? 1;
|
||||
const sourceId = typeof link.source === "object" ? link.source.id : link.source;
|
||||
const targetId = typeof link.target === "object" ? link.target.id : link.target;
|
||||
if (highlightedNodes.has(sourceId) && highlightedNodes.has(targetId)) {
|
||||
return (link.weight ?? 1) * 2.5;
|
||||
}
|
||||
return 0.3;
|
||||
},
|
||||
[highlightedNodes],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
if (graphData.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500">
|
||||
{state.viewMode === "resource" ? "Select a resource and month" : "Select a project"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" onMouseMove={handleMouseMove}>
|
||||
<ForceGraph3D
|
||||
graphData={graphData}
|
||||
nodeThreeObject={nodeThreeObject}
|
||||
nodeThreeObjectExtend={false}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeHover={onNodeHover}
|
||||
linkColor={linkColorFn}
|
||||
linkWidth={linkWidthFn}
|
||||
linkDirectionalArrowLength={4}
|
||||
linkDirectionalArrowRelPos={0.8}
|
||||
linkCurvature={0.1}
|
||||
backgroundColor="#0f172a"
|
||||
showNavInfo={false}
|
||||
warmupTicks={50}
|
||||
cooldownTicks={0}
|
||||
/>
|
||||
{/* Hover Tooltip */}
|
||||
{state.hoveredNode && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 max-w-sm rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-3 text-sm text-zinc-200 shadow-xl"
|
||||
style={{ top: mousePos.y + 16, left: mousePos.x + 16 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: state.hoveredNode.color }}
|
||||
/>
|
||||
<span className="font-semibold">{state.hoveredNode.label}</span>
|
||||
<span className="ml-auto text-xs text-zinc-500">{DOMAIN_LABELS[state.hoveredNode.domain]}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-bold">
|
||||
{state.hoveredNode.value} <span className="text-sm font-normal text-zinc-400">{state.hoveredNode.unit}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{state.hoveredNode.description}</div>
|
||||
{state.hoveredNode.formula && (
|
||||
<div className="mt-1 font-mono text-xs text-blue-400">{state.hoveredNode.formula}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DOMAIN_COLORS,
|
||||
DOMAIN_LABELS,
|
||||
} from "~/components/analytics/computation-graph/domain-colors";
|
||||
import { useComputationGraphData } from "~/components/analytics/computation-graph/useComputationGraphData";
|
||||
import ComputationGraph2D from "~/components/analytics/ComputationGraph2D";
|
||||
import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
|
||||
|
||||
type Dimension = "2d" | "3d";
|
||||
|
||||
export default function ComputationGraphClient() {
|
||||
const state = useComputationGraphData();
|
||||
const [dimension, setDimension] = useState<Dimension>("2d");
|
||||
|
||||
const {
|
||||
viewMode, setViewMode,
|
||||
resourceId, setResourceId,
|
||||
month, setMonth,
|
||||
projectId, setProjectId,
|
||||
resources, projects,
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
highlightedNodes, setHighlightedNodes,
|
||||
domainFilter, toggleDomain,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* ── Header Bar ── */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
{/* 2D / 3D Toggle */}
|
||||
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
||||
<button
|
||||
onClick={() => setDimension("2d")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
dimension === "2d"
|
||||
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-l-lg`}
|
||||
>
|
||||
2D
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDimension("3d")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
dimension === "3d"
|
||||
? "bg-zinc-800 text-white dark:bg-zinc-200 dark:text-zinc-900"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-r-lg`}
|
||||
>
|
||||
3D
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex rounded-lg border border-zinc-300 dark:border-zinc-600">
|
||||
<button
|
||||
onClick={() => setViewMode("resource")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === "resource"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-l-lg`}
|
||||
>
|
||||
Resource View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("project")}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === "project"
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
} rounded-r-lg`}
|
||||
>
|
||||
Project View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selectors */}
|
||||
{viewMode === "resource" ? (
|
||||
<>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
<option value="">Select Resource...</option>
|
||||
{resources.map((r: { id: string; displayName: string; eid: string }) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
<option value="">Select Project...</option>
|
||||
{(Array.isArray(projects) ? projects : []).map((p: { id: string; name: string; shortCode?: string | null }) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode ? `${p.shortCode} — ` : ""}{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
{graphData.nodes.length > 0 && (
|
||||
<span className="ml-auto text-xs text-zinc-500">
|
||||
{graphData.nodes.length} nodes, {graphData.links.length} links
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Clear highlight */}
|
||||
{highlightedNodes && (
|
||||
<button
|
||||
onClick={() => setHighlightedNodes(null)}
|
||||
className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Clear highlight
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Main Area ── */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* Domain Filter Sidebar */}
|
||||
<div className="flex w-48 flex-col gap-1 border-r border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<span className="mb-1 text-xs font-semibold uppercase text-zinc-500">Domains</span>
|
||||
{activeDomains.map((domain) => (
|
||||
<button
|
||||
key={domain}
|
||||
onClick={() => toggleDomain(domain)}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors ${
|
||||
domainFilter.has(domain)
|
||||
? "text-zinc-400 line-through"
|
||||
: "text-zinc-700 dark:text-zinc-300"
|
||||
} hover:bg-zinc-200 dark:hover:bg-zinc-800`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: domainFilter.has(domain) ? "#9ca3af" : DOMAIN_COLORS[domain],
|
||||
}}
|
||||
/>
|
||||
{DOMAIN_LABELS[domain]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Graph View */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center text-zinc-500">
|
||||
Loading computation graph...
|
||||
</div>
|
||||
) : dimension === "2d" ? (
|
||||
<ComputationGraph2D state={state} />
|
||||
) : (
|
||||
<ComputationGraph3D state={state} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Anomaly type badge colors ───────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_STYLES = {
|
||||
critical: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
warning: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
} as const;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
budget: "Budget",
|
||||
staffing: "Staffing",
|
||||
utilization: "Utilization",
|
||||
timeline: "Timeline",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
budget: "\u20AC", // Euro sign
|
||||
staffing: "\u2642", // Person sign
|
||||
utilization: "\u2B24", // Circle
|
||||
timeline: "\u23F0", // Clock
|
||||
};
|
||||
|
||||
// ─── Shimmer skeleton ────────────────────────────────────────────────────────
|
||||
|
||||
function Shimmer({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded bg-gray-200 dark:bg-slate-700 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<Shimmer className="h-6 w-16 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Shimmer className="h-4 w-3/4" />
|
||||
<Shimmer className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NarrativeSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Shimmer className="h-4 w-full" />
|
||||
<Shimmer className="h-4 w-5/6" />
|
||||
<Shimmer className="h-4 w-4/6" />
|
||||
<Shimmer className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Entity link helper ──────────────────────────────────────────────────────
|
||||
|
||||
function entityLink(type: string, entityId: string): string {
|
||||
if (type === "utilization") return `/resources/${entityId}`;
|
||||
return `/projects/${entityId}`;
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function InsightsPanel() {
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
const [narrativeFilter, setNarrativeFilter] = useState<string | null>(null);
|
||||
|
||||
// Fetch anomalies
|
||||
const anomaliesQuery = trpc.insights.detectAnomalies.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Fetch AI configuration status
|
||||
const aiConfigQuery = trpc.settings.getAiConfigured.useQuery(undefined, {
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
// Fetch project list for dropdown
|
||||
const projectsQuery = trpc.project.list.useQuery(
|
||||
{ page: 1, limit: 200 },
|
||||
{ staleTime: 60_000, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
// Fetch cached narrative for selected project
|
||||
const cachedNarrativeQuery = trpc.insights.getCachedNarrative.useQuery(
|
||||
{ projectId: selectedProjectId },
|
||||
{
|
||||
enabled: !!selectedProjectId,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Generate narrative mutation
|
||||
const generateMutation = trpc.insights.generateProjectNarrative.useMutation({
|
||||
onSuccess: () => {
|
||||
// Refetch the cached narrative
|
||||
void cachedNarrativeQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const anomalies = anomaliesQuery.data ?? [];
|
||||
const projects = projectsQuery.data?.projects ?? [];
|
||||
|
||||
// Filter anomalies
|
||||
const filteredAnomalies = narrativeFilter
|
||||
? anomalies.filter((a) => a.type === narrativeFilter)
|
||||
: anomalies;
|
||||
|
||||
const summaryCountsByType = anomalies.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.type] = (acc[a.type] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const criticalCount = anomalies.filter((a) => a.severity === "critical").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ── Summary cards ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{(["budget", "staffing", "utilization", "timeline"] as const).map((type) => {
|
||||
const count = summaryCountsByType[type] ?? 0;
|
||||
const isActive = narrativeFilter === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setNarrativeFilter(isActive ? null : type)}
|
||||
className={`rounded-2xl border p-4 text-left transition-all ${
|
||||
isActive
|
||||
? "border-brand-300 bg-brand-50 shadow-sm dark:border-brand-700 dark:bg-brand-950"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{TYPE_LABELS[type]}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
{anomaliesQuery.isLoading ? "-" : count}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Anomaly feed ──────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Anomaly Feed
|
||||
{criticalCount > 0 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
{criticalCount} critical
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{narrativeFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNarrativeFilter(null)}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaliesQuery.isLoading ? (
|
||||
<AnomalyListSkeleton />
|
||||
) : anomaliesQuery.error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||
Failed to load anomalies: {anomaliesQuery.error.message}
|
||||
</div>
|
||||
) : filteredAnomalies.length === 0 ? (
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-6 text-center dark:border-green-900 dark:bg-green-950/30">
|
||||
<div className="text-lg font-medium text-green-800 dark:text-green-300">
|
||||
All clear
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-green-600 dark:text-green-400">
|
||||
No anomalies detected across active projects.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredAnomalies.map((anomaly, idx) => (
|
||||
<div
|
||||
key={`${anomaly.entityId}-${anomaly.type}-${idx}`}
|
||||
className="flex items-start gap-3 rounded-xl border border-gray-100 bg-white p-4 transition-colors hover:bg-gray-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||
>
|
||||
{/* Severity badge */}
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs font-semibold ${SEVERITY_STYLES[anomaly.severity]}`}
|
||||
>
|
||||
{anomaly.severity === "critical" ? "Critical" : "Warning"}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{TYPE_ICONS[anomaly.type]} {TYPE_LABELS[anomaly.type]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
<Link
|
||||
href={entityLink(anomaly.type, anomaly.entityId) as Route}
|
||||
className="mt-1 inline-block text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{anomaly.entityName} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Project narrative ─────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Project Narrative
|
||||
</h2>
|
||||
|
||||
{!aiConfigQuery.data?.configured ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
AI is not configured.{" "}
|
||||
<Link
|
||||
href={"/admin/settings" as Route}
|
||||
className="font-medium underline hover:no-underline"
|
||||
>
|
||||
Configure AI credentials in Admin Settings
|
||||
</Link>{" "}
|
||||
to enable project narratives.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
{/* Project selector */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="project-select"
|
||||
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Select project
|
||||
</label>
|
||||
<select
|
||||
id="project-select"
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Choose a project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode} - {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedProjectId || generateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (selectedProjectId) {
|
||||
generateMutation.mutate({ projectId: selectedProjectId });
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-brand-500 dark:hover:bg-brand-600"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
"Generate Summary"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Narrative display */}
|
||||
<div className="mt-6">
|
||||
{generateMutation.isPending ? (
|
||||
<NarrativeSkeleton />
|
||||
) : generateMutation.error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||
{generateMutation.error.message}
|
||||
</div>
|
||||
) : generateMutation.data ? (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50/50 p-5 dark:border-brand-900/50 dark:bg-brand-950/20">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||
{generateMutation.data.narrative}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
Generated {new Date(generateMutation.data.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
) : cachedNarrativeQuery.data?.narrative ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-5 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||
{cachedNarrativeQuery.data.narrative}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
Previously generated{" "}
|
||||
{cachedNarrativeQuery.data.generatedAt
|
||||
? new Date(cachedNarrativeQuery.data.generatedAt).toLocaleString()
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedProjectId ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Click "Generate Summary" to create an AI-powered executive narrative for this project.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a project above to generate or view its executive summary.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
|
||||
interface SkillDistributionChartProps {
|
||||
data: { skill: string; count: number; avgProficiency: number }[];
|
||||
}
|
||||
|
||||
export default function SkillDistributionChart({ data }: SkillDistributionChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
|
||||
const SkillDistributionChart = dynamic(
|
||||
() => import("~/components/analytics/SkillDistributionChart.js"),
|
||||
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
|
||||
const PROFICIENCY_CLASSES = [
|
||||
"bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500",
|
||||
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600",
|
||||
"bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500",
|
||||
"bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500",
|
||||
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500",
|
||||
];
|
||||
|
||||
function proficiencyClasses(level: number): string {
|
||||
const idx = Math.max(0, Math.min(4, Math.round(level) - 1));
|
||||
return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!;
|
||||
}
|
||||
|
||||
function ProficiencyBadge({ value }: { value: number }) {
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
|
||||
{value} {PROFICIENCY_LABELS[value] ?? ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GapIndicator({ gap }: { gap: number }) {
|
||||
if (gap > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
|
||||
-{gap} shortage
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (gap < 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
|
||||
+{Math.abs(gap)} surplus
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
|
||||
balanced
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return "Not within 30d";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
export function SkillMarketplace() {
|
||||
const [searchSkill, setSearchSkill] = useState("");
|
||||
const [minProficiency, setMinProficiency] = useState(1);
|
||||
const [availableOnly, setAvailableOnly] = useState(false);
|
||||
|
||||
const debouncedSearch = useDebounce(searchSkill, 300);
|
||||
|
||||
const { data, isLoading, error } = trpc.resource.getSkillMarketplace.useQuery(
|
||||
{
|
||||
searchSkill: debouncedSearch || undefined,
|
||||
minProficiency,
|
||||
availableOnly,
|
||||
},
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const {
|
||||
sorted: sortedSearch,
|
||||
sortField: searchSortField,
|
||||
sortDir: searchSortDir,
|
||||
toggle: searchToggle,
|
||||
} = useTableSort(data?.searchResults ?? []);
|
||||
|
||||
const gapData = useMemo(() => data?.gapData ?? [], [data?.gapData]);
|
||||
const {
|
||||
sorted: sortedGap,
|
||||
sortField: gapSortField,
|
||||
sortDir: gapSortDir,
|
||||
toggle: gapToggle,
|
||||
} = useTableSort(gapData);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 shimmer-skeleton rounded w-64" />
|
||||
<div className="h-16 shimmer-skeleton rounded-xl" />
|
||||
<div className="h-64 shimmer-skeleton rounded-xl" />
|
||||
<div className="h-80 shimmer-skeleton rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Skill Marketplace</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Section 1: Skill Search ──────────────────────────────────────────── */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Skill Search</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by skill name..."
|
||||
value={searchSkill}
|
||||
onChange={(e) => setSearchSkill(e.target.value)}
|
||||
className="pl-8 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500 w-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min proficiency */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Min. proficiency:</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
|
||||
{[1, 2, 3, 4, 5].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
title={PROFICIENCY_LABELS[lvl]}
|
||||
onClick={() => setMinProficiency(lvl)}
|
||||
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||
minProficiency === lvl
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available only */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={availableOnly}
|
||||
onChange={(e) => setAvailableOnly(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Available in next 30 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Search results table */}
|
||||
{debouncedSearch && debouncedSearch.trim().length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
|
||||
{sortedSearch.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">
|
||||
No resources found with "{debouncedSearch}" at proficiency {minProficiency}+.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{sortedSearch.length} resource{sortedSearch.length !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Resource" field="displayName" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Chapter" field="chapter" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Skill" field="skillName" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Proficiency" field="skillProficiency" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="center" />
|
||||
<SortableColumnHeader label="Utilization" field="utilizationPercent" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="right" />
|
||||
<SortableColumnHeader label="Available From" field="availableFrom" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{sortedSearch.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/resources/${r.id}`}
|
||||
className="font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{r.displayName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{r.chapter ?? "---"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-gray-300">{r.skillName}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<ProficiencyBadge value={r.skillProficiency} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className={`text-sm font-medium ${
|
||||
r.utilizationPercent >= 90
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: r.utilizationPercent >= 70
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}`}>
|
||||
{r.utilizationPercent}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-sm">
|
||||
{formatDate(r.availableFrom)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: Skill Gap Heat Map ────────────────────────────────────── */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Skill Gap Analysis</h2>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sortedGap.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic py-4">
|
||||
No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Skill" field="skill" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} />
|
||||
<SortableColumnHeader label="Supply" field="supply" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
||||
<SortableColumnHeader label="Demand" field="demand" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
||||
<SortableColumnHeader label="Gap" field="gap" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="center" />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Visual</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{sortedGap.map((row) => {
|
||||
const maxBar = Math.max(row.supply, row.demand, 1);
|
||||
return (
|
||||
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-brand-600 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSearchSkill(row.skill);
|
||||
setMinProficiency(3);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
title={`Search for "${row.skill}"`}
|
||||
>
|
||||
{row.skill}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.supply}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.demand}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<GapIndicator gap={row.gap} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 w-48">
|
||||
<div className="flex items-center gap-1 h-4">
|
||||
<div
|
||||
className="h-3 rounded-sm bg-green-400 dark:bg-green-500 transition-all"
|
||||
style={{ width: `${(row.supply / maxBar) * 100}%`, minWidth: row.supply > 0 ? 4 : 0 }}
|
||||
title={`Supply: ${row.supply}`}
|
||||
/>
|
||||
<div
|
||||
className="h-3 rounded-sm bg-red-400 dark:bg-red-500 transition-all"
|
||||
style={{ width: `${(row.demand / maxBar) * 100}%`, minWidth: row.demand > 0 ? 4 : 0 }}
|
||||
title={`Demand: ${row.demand}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center gap-4 mt-3 px-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="w-3 h-3 rounded-sm bg-green-400 dark:bg-green-500 inline-block" /> Supply (prof. 3+)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-400 dark:bg-red-500 inline-block" /> Demand (unfilled)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section 3: Skill Distribution ────────────────────────────────────── */}
|
||||
{(data?.distribution ?? []).length > 0 && (
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Top 20 Skills by Resource Count
|
||||
</h2>
|
||||
<SkillDistributionChart data={data?.distribution ?? []} />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
Bar color = average proficiency (light to dark = low to high)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
const SkillDistributionChart = dynamic(
|
||||
() => import("~/components/analytics/SkillDistributionChart.js"),
|
||||
{ ssr: false, loading: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"];
|
||||
|
||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||
const PROFICIENCY_CLASSES = [
|
||||
@@ -87,8 +81,9 @@ export function SkillsAnalytics() {
|
||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function exportXlsx() {
|
||||
async function exportXlsx() {
|
||||
if (!data) return;
|
||||
const XLSX = await import("xlsx");
|
||||
const rows = data.aggregated.map((e) => ({
|
||||
Skill: e.skill,
|
||||
Category: e.category,
|
||||
@@ -117,9 +112,9 @@ export function SkillsAnalytics() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-64" />
|
||||
<div className="h-64 bg-gray-100 rounded-xl" />
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 shimmer-skeleton rounded w-64" />
|
||||
<div className="h-64 shimmer-skeleton rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -413,21 +408,7 @@ export function SkillsAnalytics() {
|
||||
{top20.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Top Skills by Resource Count</h2>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top20} layout="vertical" margin={{ left: 160, right: 20, top: 0, bottom: 0 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="skill" tick={{ fontSize: 11 }} width={155} />
|
||||
<Tooltip
|
||||
formatter={(value: number | undefined) => [`${value ?? 0} resources`, "Count"] as [string, string]}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 8 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{top20.map((entry) => (
|
||||
<Cell key={entry.skill} fill={PROFICIENCY_SVG_COLORS[Math.max(0, Math.min(4, Math.round(entry.avgProficiency) - 1))] ?? "#6b7280"} strokeWidth={0} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<SkillDistributionChart data={top20} />
|
||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// ─── Domain Types & Colors for the 3D Computation Graph ────────────────────
|
||||
|
||||
export type Domain =
|
||||
| "INPUT"
|
||||
| "SAH"
|
||||
| "ALLOCATION"
|
||||
| "RULES"
|
||||
| "CHARGEABILITY"
|
||||
| "BUDGET"
|
||||
| "ESTIMATE"
|
||||
| "COMMERCIAL"
|
||||
| "EXPERIENCE"
|
||||
| "EFFORT"
|
||||
| "SPREAD";
|
||||
|
||||
export const DOMAIN_COLORS: Record<Domain, string> = {
|
||||
INPUT: "#94a3b8",
|
||||
SAH: "#3b82f6",
|
||||
ALLOCATION: "#f97316",
|
||||
RULES: "#8b5cf6",
|
||||
CHARGEABILITY: "#22c55e",
|
||||
BUDGET: "#ef4444",
|
||||
ESTIMATE: "#06b6d4",
|
||||
COMMERCIAL: "#f59e0b",
|
||||
EXPERIENCE: "#ec4899",
|
||||
EFFORT: "#14b8a6",
|
||||
SPREAD: "#6366f1",
|
||||
};
|
||||
|
||||
export const DOMAIN_LABELS: Record<Domain, string> = {
|
||||
INPUT: "Inputs",
|
||||
SAH: "SAH",
|
||||
ALLOCATION: "Allocation",
|
||||
RULES: "Rules Engine",
|
||||
CHARGEABILITY: "Chargeability",
|
||||
BUDGET: "Budget",
|
||||
ESTIMATE: "Estimates",
|
||||
COMMERCIAL: "Commercial",
|
||||
EXPERIENCE: "Experience Mult.",
|
||||
EFFORT: "Effort Rules",
|
||||
SPREAD: "Monthly Spread",
|
||||
};
|
||||
|
||||
// ─── Graph Node / Link Types ────────────────────────────────────────────────
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | string;
|
||||
unit: string;
|
||||
domain: Domain;
|
||||
description: string;
|
||||
formula?: string;
|
||||
level: number; // 0=Input, 1=Intermediate, 2=Derived, 3=Output
|
||||
// react-force-graph position hints
|
||||
fx?: number;
|
||||
fy?: number;
|
||||
fz?: number;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
formula: string;
|
||||
weight: number; // 1-3 for line thickness
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ─── Resource View Mode ─────────────────────────────────────────────────────
|
||||
|
||||
export const RESOURCE_VIEW_DOMAINS: Domain[] = [
|
||||
"INPUT",
|
||||
"SAH",
|
||||
"ALLOCATION",
|
||||
"RULES",
|
||||
"CHARGEABILITY",
|
||||
"BUDGET",
|
||||
];
|
||||
|
||||
export const PROJECT_VIEW_DOMAINS: Domain[] = [
|
||||
"INPUT",
|
||||
"ESTIMATE",
|
||||
"COMMERCIAL",
|
||||
"EXPERIENCE",
|
||||
"EFFORT",
|
||||
"SPREAD",
|
||||
"BUDGET",
|
||||
];
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { GraphNode, GraphLink, Domain } from "./domain-colors";
|
||||
import { DOMAIN_COLORS } from "./domain-colors";
|
||||
|
||||
// ─── Layout Constants ───────────────────────────────────────────────────────
|
||||
|
||||
const LEVEL_Y_SPACING = 120;
|
||||
const DOMAIN_X_SPACING = 200;
|
||||
const NODE_Z_JITTER = 40;
|
||||
|
||||
// Domain → X position offset for clustering
|
||||
const DOMAIN_X_OFFSETS: Partial<Record<Domain, number>> = {
|
||||
INPUT: 0,
|
||||
SAH: -300,
|
||||
ALLOCATION: -100,
|
||||
RULES: 100,
|
||||
CHARGEABILITY: 300,
|
||||
BUDGET: 500,
|
||||
ESTIMATE: -200,
|
||||
COMMERCIAL: 0,
|
||||
EXPERIENCE: 200,
|
||||
EFFORT: -400,
|
||||
SPREAD: 400,
|
||||
};
|
||||
|
||||
// ─── Position Calculator ────────────────────────────────────────────────────
|
||||
|
||||
export interface PositionedNode extends GraphNode {
|
||||
fx: number;
|
||||
fy: number;
|
||||
fz: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ForceGraphData {
|
||||
nodes: PositionedNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns 3D positions to nodes based on their level (Y) and domain (X),
|
||||
* with slight Z jitter to prevent overlap.
|
||||
*/
|
||||
export function buildForceGraphData(
|
||||
nodes: GraphNode[],
|
||||
links: GraphLink[],
|
||||
): ForceGraphData {
|
||||
// Group nodes by domain+level to spread within each cluster
|
||||
const groups = new Map<string, GraphNode[]>();
|
||||
for (const node of nodes) {
|
||||
const key = `${node.domain}:${node.level}`;
|
||||
const arr = groups.get(key) ?? [];
|
||||
arr.push(node);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
|
||||
const positionedNodes: PositionedNode[] = nodes.map((node) => {
|
||||
const key = `${node.domain}:${node.level}`;
|
||||
const siblings = groups.get(key) ?? [node];
|
||||
const idx = siblings.indexOf(node);
|
||||
const count = siblings.length;
|
||||
|
||||
// Center siblings around the domain X offset
|
||||
const baseX = DOMAIN_X_OFFSETS[node.domain] ?? 0;
|
||||
const spreadX = count > 1 ? (idx - (count - 1) / 2) * 80 : 0;
|
||||
|
||||
return {
|
||||
...node,
|
||||
fx: baseX + spreadX,
|
||||
fy: node.level * LEVEL_Y_SPACING,
|
||||
fz: (idx % 2 === 0 ? 1 : -1) * NODE_Z_JITTER * (Math.floor(idx / 2) + 1) * 0.5,
|
||||
color: DOMAIN_COLORS[node.domain],
|
||||
};
|
||||
});
|
||||
|
||||
// Filter links to only reference existing node IDs
|
||||
const nodeIds = new Set(positionedNodes.map((n) => n.id));
|
||||
const validLinks = links.filter((link) => nodeIds.has(link.source) && nodeIds.has(link.target));
|
||||
|
||||
return { nodes: positionedNodes, links: validLinks };
|
||||
}
|
||||
|
||||
// ─── Highlight Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a clicked node, returns the set of node IDs in its
|
||||
* upstream (ancestors) and downstream (descendants) path.
|
||||
*/
|
||||
export function getConnectedNodeIds(
|
||||
nodeId: string,
|
||||
links: GraphLink[],
|
||||
): Set<string> {
|
||||
const connected = new Set<string>([nodeId]);
|
||||
|
||||
// BFS upstream (nodes that feed into this one)
|
||||
const queue = [nodeId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const link of links) {
|
||||
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
|
||||
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
|
||||
if (targetId === current && !connected.has(sourceId)) {
|
||||
connected.add(sourceId);
|
||||
queue.push(sourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BFS downstream (nodes this one feeds into)
|
||||
const queue2 = [nodeId];
|
||||
const visited = new Set<string>([nodeId]);
|
||||
while (queue2.length > 0) {
|
||||
const current = queue2.shift()!;
|
||||
for (const link of links) {
|
||||
const targetId = typeof link.target === "object" ? (link.target as { id: string }).id : link.target;
|
||||
const sourceId = typeof link.source === "object" ? (link.source as { id: string }).id : link.source;
|
||||
if (sourceId === current && !visited.has(targetId)) {
|
||||
connected.add(targetId);
|
||||
visited.add(targetId);
|
||||
queue2.push(targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as THREE from "three";
|
||||
import type { PositionedNode } from "./graph-data";
|
||||
|
||||
// ─── Canvas-based node sprites ──────────────────────────────────────────────
|
||||
|
||||
const spriteCache = new Map<string, THREE.Sprite>();
|
||||
|
||||
/**
|
||||
* Creates a Three.js sprite for a graph node: colored circle with value label.
|
||||
*/
|
||||
export function createNodeSprite(node: PositionedNode): THREE.Sprite {
|
||||
const cacheKey = `${node.id}:${node.value}:${node.color}`;
|
||||
const cached = spriteCache.get(cacheKey);
|
||||
if (cached) return cached.clone();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const size = 512;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size / 2 - 16;
|
||||
|
||||
// Outer glow ring
|
||||
const gradient = ctx.createRadialGradient(cx, cy, radius * 0.8, cx, cy, radius);
|
||||
gradient.addColorStop(0, node.color + "aa");
|
||||
gradient.addColorStop(1, node.color + "00");
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// Dark background circle for contrast
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#0f172a";
|
||||
ctx.fill();
|
||||
|
||||
// Colored border ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius * 0.78, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = node.color;
|
||||
ctx.lineWidth = 8;
|
||||
ctx.stroke();
|
||||
|
||||
// Subtle inner fill
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius * 0.74, 0, Math.PI * 2);
|
||||
ctx.fillStyle = node.color + "20";
|
||||
ctx.fill();
|
||||
|
||||
// Label (top)
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 36px system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.shadowColor = "#000000";
|
||||
ctx.shadowBlur = 6;
|
||||
|
||||
const label = node.label.length > 16 ? node.label.slice(0, 14) + "..." : node.label;
|
||||
ctx.fillText(label, cx, cy - 28);
|
||||
|
||||
// Value (bottom) — brighter, larger
|
||||
ctx.fillStyle = node.color;
|
||||
ctx.font = "bold 44px system-ui, sans-serif";
|
||||
ctx.shadowBlur = 4;
|
||||
const valueStr = typeof node.value === "number" ? node.value.toFixed(1) : String(node.value);
|
||||
const displayValue = valueStr.length > 12 ? valueStr.slice(0, 10) + "..." : valueStr;
|
||||
ctx.fillText(displayValue, cx, cy + 24);
|
||||
|
||||
// Unit (small, below value)
|
||||
if (node.unit) {
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.font = "24px system-ui, sans-serif";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillText(node.unit, cx, cy + 60);
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.set(50, 50, 1);
|
||||
|
||||
spriteCache.set(cacheKey, sprite);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dimmed version of a node sprite (for non-highlighted nodes).
|
||||
*/
|
||||
export function createDimmedNodeSprite(node: PositionedNode): THREE.Sprite {
|
||||
const sprite = createNodeSprite({ ...node, color: "#4b5563" });
|
||||
sprite.material.opacity = 0.3;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
// ─── Link particle rendering ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a color for a link based on the source node's domain color.
|
||||
*/
|
||||
export function getLinkColor(
|
||||
link: { source: string | { color?: string }; weight: number },
|
||||
opacity = 0.4,
|
||||
): string {
|
||||
const sourceColor = typeof link.source === "object" && link.source.color
|
||||
? link.source.color
|
||||
: "#6b7280";
|
||||
// Convert hex to rgba
|
||||
const r = parseInt(sourceColor.slice(1, 3), 16);
|
||||
const g = parseInt(sourceColor.slice(3, 5), 16);
|
||||
const b = parseInt(sourceColor.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${opacity})`;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import {
|
||||
RESOURCE_VIEW_DOMAINS,
|
||||
PROJECT_VIEW_DOMAINS,
|
||||
type Domain,
|
||||
type GraphNode,
|
||||
} from "./domain-colors";
|
||||
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
export interface ComputationGraphState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (m: ViewMode) => void;
|
||||
resourceId: string;
|
||||
setResourceId: (id: string) => void;
|
||||
month: string;
|
||||
setMonth: (m: string) => void;
|
||||
projectId: string;
|
||||
setProjectId: (id: string) => void;
|
||||
resources: Array<{ id: string; displayName: string; eid: string }>;
|
||||
projects: Array<{ id: string; name: string; shortCode?: string | null }>;
|
||||
isLoading: boolean;
|
||||
activeDomains: Domain[];
|
||||
graphData: ForceGraphData;
|
||||
highlightedNodes: Set<string> | null;
|
||||
setHighlightedNodes: (s: Set<string> | null) => void;
|
||||
hoveredNode: PositionedNode | null;
|
||||
setHoveredNode: (n: PositionedNode | null) => void;
|
||||
domainFilter: Set<Domain>;
|
||||
toggleDomain: (domain: Domain) => void;
|
||||
handleNodeClick: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function useComputationGraphData(): ComputationGraphState {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||
const [resourceId, setResourceId] = useState<string>("");
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
});
|
||||
const [projectId, setProjectId] = useState<string>("");
|
||||
const [highlightedNodes, setHighlightedNodes] = useState<Set<string> | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<PositionedNode | null>(null);
|
||||
const [domainFilter, setDomainFilter] = useState<Set<Domain>>(new Set());
|
||||
|
||||
// Load selectors
|
||||
const { data: resourceData } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const resources = resourceData?.resources ?? [];
|
||||
|
||||
const { data: projectData } = trpc.project.list.useQuery(
|
||||
{},
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projects: Array<{ id: string; name: string; shortCode?: string | null }> = (projectData as any)?.projects ?? (projectData as any) ?? [];
|
||||
|
||||
// Auto-select first resource/project
|
||||
useEffect(() => {
|
||||
if (!resourceId && resources.length > 0) {
|
||||
setResourceId(resources[0]!.id);
|
||||
}
|
||||
}, [resources, resourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId && Array.isArray(projects) && projects.length > 0) {
|
||||
setProjectId(projects[0]!.id);
|
||||
}
|
||||
}, [projects, projectId]);
|
||||
|
||||
// Fetch graph data
|
||||
const { data: resourceGraphData, isLoading: resourceLoading } = trpc.computationGraph.getResourceData.useQuery(
|
||||
{ resourceId, month },
|
||||
{ enabled: viewMode === "resource" && !!resourceId, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const { data: projectGraphData, isLoading: projectLoading } = trpc.computationGraph.getProjectData.useQuery(
|
||||
{ projectId },
|
||||
{ enabled: viewMode === "project" && !!projectId, staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const rawData = viewMode === "resource" ? resourceGraphData : projectGraphData;
|
||||
const isLoading = viewMode === "resource" ? resourceLoading : projectLoading;
|
||||
const activeDomains = viewMode === "resource" ? RESOURCE_VIEW_DOMAINS : PROJECT_VIEW_DOMAINS;
|
||||
|
||||
// Build graph data with positions
|
||||
const graphData = useMemo(() => {
|
||||
if (!rawData) return { nodes: [], links: [] };
|
||||
let filteredNodes = rawData.nodes as GraphNode[];
|
||||
if (domainFilter.size > 0) {
|
||||
filteredNodes = filteredNodes.filter((nd: GraphNode) => !domainFilter.has(nd.domain as Domain));
|
||||
}
|
||||
const filteredNodeIds = new Set(filteredNodes.map((nd: GraphNode) => nd.id));
|
||||
const filteredLinks = rawData.links.filter(
|
||||
(lk: { source: string; target: string }) =>
|
||||
filteredNodeIds.has(lk.source) && filteredNodeIds.has(lk.target),
|
||||
);
|
||||
return buildForceGraphData(filteredNodes, filteredLinks);
|
||||
}, [rawData, domainFilter]);
|
||||
|
||||
// Domain filter toggle
|
||||
const toggleDomain = useCallback((domain: Domain) => {
|
||||
setDomainFilter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domain)) {
|
||||
next.delete(domain);
|
||||
} else {
|
||||
next.add(domain);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Node click → highlight path
|
||||
const handleNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (highlightedNodes?.has(nodeId)) {
|
||||
setHighlightedNodes(null);
|
||||
} else {
|
||||
const connected = getConnectedNodeIds(nodeId, graphData.links);
|
||||
setHighlightedNodes(connected);
|
||||
}
|
||||
},
|
||||
[graphData.links, highlightedNodes],
|
||||
);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
resourceId,
|
||||
setResourceId,
|
||||
month,
|
||||
setMonth,
|
||||
projectId,
|
||||
setProjectId,
|
||||
resources,
|
||||
projects,
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
highlightedNodes,
|
||||
setHighlightedNodes,
|
||||
hoveredNode,
|
||||
setHoveredNode,
|
||||
domainFilter,
|
||||
toggleDomain,
|
||||
handleNodeClick,
|
||||
};
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ChatMessage, TypingIndicator } from "./ChatMessage.js";
|
||||
|
||||
/** Map route prefixes to human-readable page context for the AI */
|
||||
const ROUTE_CONTEXT: Record<string, string> = {
|
||||
"/dashboard": "Dashboard — Übersicht mit KPIs, aktive Projekte, Ressourcen-Auslastung",
|
||||
"/timeline": "Timeline — Gantt-artige Ansicht aller Allokationen und Projekte",
|
||||
"/allocations": "Allokationen — Liste aller Zuweisungen von Ressourcen zu Projekten",
|
||||
"/staffing": "Staffing — Projektbesetzung und Kapazitätsplanung",
|
||||
"/resources": "Ressourcen — Liste aller Mitarbeiter mit Details (FTE, LCR, Skills, Chapter)",
|
||||
"/projects": "Projekte — Liste aller Projekte mit Budget, Status, Zeitraum",
|
||||
"/roles": "Rollen — Verwaltung der verfügbaren Rollen",
|
||||
"/estimates": "Estimating — Aufwandsschätzungen für Projekte",
|
||||
"/vacations/my": "Meine Urlaube — Eigene Urlaubsanträge und Saldo",
|
||||
"/vacations": "Urlaubsverwaltung — Alle Urlaubsanträge, Genehmigungen, Team-Kalender",
|
||||
"/analytics/skills": "Skills Analytics — Skill-Verteilung und -Analyse über alle Ressourcen",
|
||||
"/analytics/computation-graph": "Computation Graph — Berechnungsvisualisierung für Budget/Kosten",
|
||||
"/reports/chargeability": "Chargeability Report — Auslastungsanalyse pro Ressource",
|
||||
"/admin/settings": "Admin-Einstellungen — System-Konfiguration, AI-Credentials, SMTP",
|
||||
"/admin/users": "Benutzerverwaltung — Rollen, Berechtigungen, Zugänge",
|
||||
};
|
||||
|
||||
function resolvePageContext(pathname: string): string {
|
||||
// Try exact match first, then prefix match (longest first)
|
||||
const exact = ROUTE_CONTEXT[pathname];
|
||||
if (exact) return exact;
|
||||
const sorted = Object.keys(ROUTE_CONTEXT).sort((a, b) => b.length - a.length);
|
||||
for (const prefix of sorted) {
|
||||
const ctx = ROUTE_CONTEXT[prefix];
|
||||
if (pathname.startsWith(prefix) && ctx) return ctx;
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatDrawer({ onClose }: { onClose: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const chatMutation = trpc.assistant.chat.useMutation();
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
setInput("");
|
||||
setError(null);
|
||||
|
||||
const userMsg: Message = { role: "user", content: text };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const reply = await chatMutation.mutateAsync({
|
||||
messages: updated.map((m) => ({ role: m.role, content: m.content })),
|
||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||
});
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Something went wrong";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages, chatMutation]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-gray-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-brand-600 text-white">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Planarchy Assistant</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-slate-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
<svg className="mb-3 h-10 w-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||
</svg>
|
||||
<p className="font-medium">Frag mich etwas!</p>
|
||||
<p className="mt-1 text-xs">z.B. "Welche Ressourcen gibt es?" oder "Budget von Z033T593?"</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} role={msg.role} content={msg.content} />
|
||||
))}
|
||||
{isLoading && <TypingIndicator />}
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 px-4 py-3 dark:border-slate-700">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Nachricht eingeben..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-brand-500"
|
||||
style={{ maxHeight: "120px" }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white transition-colors hover:bg-brand-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -319,7 +319,7 @@ export function BlueprintsClient() {
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse h-36" />
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 shimmer-skeleton h-36" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -354,11 +354,11 @@ export function BlueprintsClient() {
|
||||
aria-label="Select all blueprints"
|
||||
/>
|
||||
</th>
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
|
||||
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} />
|
||||
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" />
|
||||
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Blueprint name. Defines a template of dynamic fields for resources or projects." />
|
||||
<SortableColumnHeader label="Target" field="target" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} tooltip="Whether this blueprint applies to Resource or Project entities." />
|
||||
<SortableColumnHeader label="Fields" field="fieldCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Number of custom dynamic fields defined in this blueprint." />
|
||||
<SortableColumnHeader label="Staffing Presets" field="presetCount" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Role presets for project staffing demands. Only applicable to PROJECT blueprints." />
|
||||
<SortableColumnHeader label="Global" field="global" sortField={sortField} sortDir={sortDir} onSort={handleSortRequest} align="center" tooltip="Global blueprints expose their fields as columns across all entities of the target type." />
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface CommentInputProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
parentId?: string;
|
||||
onSubmit: (body: string) => void;
|
||||
onCancel?: () => void;
|
||||
isSubmitting?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
interface MentionCandidate {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function CommentInput({
|
||||
entityType: _entityType,
|
||||
entityId: _entityId,
|
||||
parentId: _parentId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
placeholder = "Write a comment... Use @ to mention someone",
|
||||
autoFocus = false,
|
||||
}: CommentInputProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Fetch users for mention autocomplete (only when needed)
|
||||
const usersQuery = trpc.user.listAssignable.useQuery(undefined, {
|
||||
enabled: mentionQuery !== null,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const users = usersQuery.data ?? [];
|
||||
|
||||
// Filter users based on mention query
|
||||
const filteredUsers: MentionCandidate[] =
|
||||
mentionQuery !== null
|
||||
? users.filter((u) => {
|
||||
const q = mentionQuery.toLowerCase();
|
||||
return (
|
||||
(u.name?.toLowerCase().includes(q) ?? false) ||
|
||||
u.email.toLowerCase().includes(q)
|
||||
);
|
||||
}).slice(0, 8)
|
||||
: [];
|
||||
|
||||
// Reset mention index when filtered list changes
|
||||
useEffect(() => {
|
||||
setMentionIndex(0);
|
||||
}, [mentionQuery]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart ?? value.length;
|
||||
setBody(value);
|
||||
setCursorPosition(cursor);
|
||||
|
||||
// Detect if we are in a @mention context
|
||||
const textBeforeCursor = value.slice(0, cursor);
|
||||
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
|
||||
|
||||
if (atMatch) {
|
||||
setMentionQuery(atMatch[1]!);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const insertMention = useCallback(
|
||||
(user: MentionCandidate) => {
|
||||
const textBeforeCursor = body.slice(0, cursorPosition);
|
||||
const textAfterCursor = body.slice(cursorPosition);
|
||||
|
||||
// Find the @ that triggered this mention
|
||||
const atMatch = textBeforeCursor.match(/@([^\s@]*)$/);
|
||||
if (!atMatch) return;
|
||||
|
||||
const atStart = textBeforeCursor.length - atMatch[0].length;
|
||||
const displayName = user.name ?? user.email;
|
||||
const mentionText = `@[${displayName}](${user.id}) `;
|
||||
|
||||
const newBody =
|
||||
textBeforeCursor.slice(0, atStart) + mentionText + textAfterCursor;
|
||||
setBody(newBody);
|
||||
setMentionQuery(null);
|
||||
|
||||
// Focus and set cursor position
|
||||
const newCursorPos = atStart + mentionText.length;
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.selectionStart = newCursorPos;
|
||||
ta.selectionEnd = newCursorPos;
|
||||
}
|
||||
});
|
||||
},
|
||||
[body, cursorPosition],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Handle mention dropdown navigation
|
||||
if (mentionQuery !== null && filteredUsers.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) =>
|
||||
prev < filteredUsers.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredUsers.length - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertMention(filteredUsers[mentionIndex]!);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Submit on Ctrl+Enter / Cmd+Enter
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (body.trim().length > 0 && !isSubmitting) {
|
||||
onSubmit(body.trim());
|
||||
setBody("");
|
||||
}
|
||||
}
|
||||
},
|
||||
[mentionQuery, filteredUsers, mentionIndex, insertMention, body, isSubmitting, onSubmit],
|
||||
);
|
||||
|
||||
function handleSubmitClick() {
|
||||
if (body.trim().length > 0 && !isSubmitting) {
|
||||
onSubmit(body.trim());
|
||||
setBody("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
disabled={isSubmitting}
|
||||
rows={3}
|
||||
className="w-full rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-3 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:border-brand-400 dark:focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-brand-100 dark:focus:ring-sky-900 disabled:opacity-60 resize-y"
|
||||
/>
|
||||
|
||||
{/* Mention autocomplete dropdown */}
|
||||
{mentionQuery !== null && filteredUsers.length > 0 && (
|
||||
<div className="absolute left-0 bottom-full mb-1 z-50 w-72 rounded-xl border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-lg overflow-hidden">
|
||||
{filteredUsers.map((user, idx) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent textarea blur
|
||||
insertMention(user);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${
|
||||
idx === mentionIndex
|
||||
? "bg-brand-50 dark:bg-sky-900/40 text-brand-700 dark:text-sky-200"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 text-xs font-semibold text-brand-700 dark:text-sky-200">
|
||||
{(user.name ?? user.email).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
<span className="font-medium">{user.name ?? "—"}</span>
|
||||
<span className="ml-1 text-gray-400 text-xs">{user.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">
|
||||
Ctrl+Enter to submit
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitClick}
|
||||
disabled={body.trim().length === 0 || isSubmitting}
|
||||
className="rounded-lg bg-brand-600 dark:bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-brand-700 dark:hover:bg-sky-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { CommentInput } from "./CommentInput.js";
|
||||
|
||||
interface CommentAuthor {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface CommentReply {
|
||||
id: string;
|
||||
body: string;
|
||||
resolved: boolean;
|
||||
createdAt: Date | string;
|
||||
author: CommentAuthor;
|
||||
}
|
||||
|
||||
interface CommentItem {
|
||||
id: string;
|
||||
body: string;
|
||||
resolved: boolean;
|
||||
createdAt: Date | string;
|
||||
author: CommentAuthor;
|
||||
replies: CommentReply[];
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
|
||||
if (diffMinutes < 1) return "just now";
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function AuthorAvatar({ author }: { author: CommentAuthor }) {
|
||||
const initials = author.name
|
||||
? author.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
: author.email.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-100 dark:bg-sky-800 text-xs font-semibold text-brand-700 dark:text-sky-200">
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render comment body with @mention highlights.
|
||||
* Transforms @[Name](userId) into styled spans.
|
||||
*/
|
||||
function CommentBody({ body }: { body: string }) {
|
||||
const parts: Array<{ type: "text" | "mention"; value: string }> = [];
|
||||
const regex = /@\[([^\]]+)\]\([^)]+\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(body)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: "text", value: body.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ type: "mention", value: `@${match[1]}` });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < body.length) {
|
||||
parts.push({ type: "text", value: body.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words">
|
||||
{parts.map((part, i) =>
|
||||
part.type === "mention" ? (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded bg-brand-50 dark:bg-sky-900/50 px-1 py-0.5 text-brand-700 dark:text-sky-300 font-medium text-xs"
|
||||
>
|
||||
{part.value}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{part.value}</span>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleComment({
|
||||
comment,
|
||||
entityType,
|
||||
entityId,
|
||||
isReply = false,
|
||||
}: {
|
||||
comment: CommentItem | CommentReply;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
isReply?: boolean;
|
||||
}) {
|
||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setShowReplyInput(false);
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const resolveMutation = trpc.comment.resolve.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.comment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const isResolved = comment.resolved;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative",
|
||||
isResolved && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className={clsx("flex gap-3", isReply && "ml-10")}>
|
||||
<AuthorAvatar author={comment.author} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{comment.author.name ?? comment.author.email}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(comment.createdAt)}
|
||||
</span>
|
||||
{isResolved && (
|
||||
<span className="rounded-full bg-emerald-100 dark:bg-emerald-900/50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
Resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={clsx(isResolved && "line-through decoration-gray-300 dark:decoration-gray-600")}>
|
||||
<CommentBody body={comment.body} />
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-1 flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!isReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReplyInput((prev) => !prev)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
Reply
|
||||
</button>
|
||||
)}
|
||||
{!isReply && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
resolveMutation.mutate({
|
||||
id: comment.id,
|
||||
resolved: !isResolved,
|
||||
})
|
||||
}
|
||||
disabled={resolveMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400"
|
||||
>
|
||||
{isResolved ? "Unresolve" : "Resolve"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this comment?")) {
|
||||
deleteMutation.mutate({ id: comment.id });
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-xs text-gray-400 hover:text-rose-600 dark:hover:text-rose-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline reply input */}
|
||||
{showReplyInput && (
|
||||
<div className="mt-3">
|
||||
<CommentInput
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
parentId={comment.id}
|
||||
onSubmit={(replyBody) => {
|
||||
createMutation.mutate({
|
||||
entityType,
|
||||
entityId,
|
||||
parentId: comment.id,
|
||||
body: replyBody,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowReplyInput(false)}
|
||||
isSubmitting={createMutation.isPending}
|
||||
placeholder="Write a reply..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render replies */}
|
||||
{"replies" in comment && comment.replies.length > 0 && (
|
||||
<div className="mt-3 space-y-3 border-l-2 border-gray-100 dark:border-gray-700 pl-2">
|
||||
{comment.replies.map((reply) => (
|
||||
<SingleComment
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
isReply
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({ entityType, entityId }: CommentThreadProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const commentsQuery = trpc.comment.list.useQuery(
|
||||
{ entityType, entityId },
|
||||
{ staleTime: 10_000 },
|
||||
);
|
||||
|
||||
const createMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.comment.list.invalidate({ entityType, entityId });
|
||||
void utils.comment.count.invalidate({ entityType, entityId });
|
||||
},
|
||||
});
|
||||
|
||||
const comments = (commentsQuery.data ?? []) as CommentItem[];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Comment list */}
|
||||
{commentsQuery.isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="h-8 w-8 shimmer-skeleton rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-12 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-6">
|
||||
No comments yet. Start the conversation below.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<SingleComment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New comment input */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<CommentInput
|
||||
entityType={entityType}
|
||||
entityId={entityId}
|
||||
onSubmit={(body) => {
|
||||
createMutation.mutate({
|
||||
entityType,
|
||||
entityId,
|
||||
body,
|
||||
});
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import "react-resizable/css/styles.css";
|
||||
|
||||
function WidgetFallback() {
|
||||
return (
|
||||
<div className="animate-pulse h-full w-full flex flex-col gap-3 p-4">
|
||||
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-full bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-4/5 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-3/5 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-full w-full flex flex-col gap-3 p-4">
|
||||
<div className="h-3 w-32 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-full shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-4/5 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-3/5 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface WidgetContainerProps {
|
||||
title: string;
|
||||
onRemove: () => void;
|
||||
@@ -9,9 +11,12 @@ interface WidgetContainerProps {
|
||||
|
||||
export function WidgetContainer({ title, onRemove, children, isDragging }: WidgetContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden ${
|
||||
isDragging ? "shadow-lg border-brand-300" : ""
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className={`flex flex-col h-full bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden transition-colors duration-200 ${
|
||||
isDragging ? "shadow-lg border-brand-300" : "hover:border-brand-200 dark:hover:border-brand-800"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -34,6 +39,6 @@ export function WidgetContainer({ title, onRemove, children, isDragging }: Widge
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,18 @@ export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "my-projects")!,
|
||||
component: lazy(() => import("./widgets/MyProjectsWidget.js").then((m) => ({ default: m.MyProjectsWidget }))),
|
||||
},
|
||||
"budget-forecast": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "budget-forecast")!,
|
||||
component: lazy(() => import("./widgets/BudgetForecastWidget.js").then((m) => ({ default: m.BudgetForecastWidget }))),
|
||||
},
|
||||
"skill-gap": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "skill-gap")!,
|
||||
component: lazy(() => import("./widgets/SkillGapWidget.js").then((m) => ({ default: m.SkillGapWidget }))),
|
||||
},
|
||||
"project-health": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-health")!,
|
||||
component: lazy(() => import("./widgets/ProjectHealthWidget.js").then((m) => ({ default: m.ProjectHealthWidget }))),
|
||||
},
|
||||
};
|
||||
|
||||
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
|
||||
function colorClass(pct: number): string {
|
||||
if (pct > 90) return "bg-red-500";
|
||||
if (pct > 70) return "bg-amber-400";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
function textColorClass(pct: number): string {
|
||||
if (pct > 90) return "text-red-700";
|
||||
if (pct > 70) return "text-amber-700";
|
||||
return "text-green-700";
|
||||
}
|
||||
|
||||
export function BudgetForecastWidget(_props: WidgetProps) {
|
||||
const { data, isLoading } = trpc.dashboard.getBudgetForecast.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-12 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No active projects with budgets.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Budget Usage</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Burn/mo</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Exhaustion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.shortCode} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[140px] truncate">
|
||||
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
|
||||
{row.projectName}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${colorClass(row.pctUsed)}`}
|
||||
style={{ width: `${Math.min(row.pctUsed, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[11px] font-semibold tabular-nums w-10 text-right ${textColorClass(row.pctUsed)}`}>
|
||||
{row.pctUsed}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 tabular-nums">
|
||||
{row.burnRate > 0
|
||||
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
|
||||
: "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 tabular-nums">
|
||||
{row.estimatedExhaustionDate ?? "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,20 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } fr
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { AnimatedNumber } from "~/components/ui/AnimatedNumber.js";
|
||||
|
||||
function UtilizationBar({ percent }: { percent: number }) {
|
||||
const barColor =
|
||||
percent >= 80 ? "bg-green-500" : percent >= 50 ? "bg-amber-500" : "bg-red-500";
|
||||
return (
|
||||
<div className="h-1 w-full rounded-full bg-gray-100 dark:bg-gray-800 mt-0.5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TopSortKey = "name" | "actual" | "expected";
|
||||
type WatchSortKey = "name" | "actual" | "target";
|
||||
@@ -120,21 +134,21 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 pt-1">
|
||||
<div className="h-2 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="h-2 w-32 shimmer-skeleton rounded" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-2 py-1">
|
||||
<div className="h-3 w-4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-4 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-10 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 mt-1 pt-2">
|
||||
<div className="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-2 w-20 shimmer-skeleton rounded mb-2" />
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-2 py-1">
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
@@ -146,7 +160,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
|
||||
const rawTop = data?.top ?? [];
|
||||
const rawWatch = data?.watchlist ?? [];
|
||||
const month = data?.month ?? "";
|
||||
const month = (data?.month as string) ?? "";
|
||||
|
||||
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
|
||||
const mult = topDir === "asc" ? 1 : -1;
|
||||
@@ -237,6 +251,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include proposed
|
||||
<InfoTooltip content="When enabled, PROPOSED bookings and imported TBD planning rows are also counted toward chargeability." />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -248,6 +263,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Show departed
|
||||
<InfoTooltip content="When enabled, resources who have left the company are included in the lists." />
|
||||
</label>
|
||||
<FilterDropdown label={selectedCountryLabel}>
|
||||
<div className="space-y-2">
|
||||
@@ -295,8 +311,9 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
|
||||
}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
||||
Top Chargeability
|
||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleTop.length}/{top.length}
|
||||
</span>
|
||||
@@ -354,16 +371,21 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{visibleTop.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700">
|
||||
{r.actualChargeability}%
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">{r.expectedChargeability}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -380,8 +402,9 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
|
||||
}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
||||
Watchlist <span className="font-normal text-gray-400">(below target)</span>
|
||||
<InfoTooltip content="Resources whose actual chargeability is more than 15 percentage points below their individual target. These may need more project assignments." />
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleWatchlist.length}/{watchlist.length}
|
||||
</span>
|
||||
@@ -432,15 +455,20 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{visibleWatchlist.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600">
|
||||
{r.actualChargeability}%
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">{r.chargeabilityTarget}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
type GroupBy = "project" | "person" | "chapter";
|
||||
|
||||
@@ -30,17 +31,17 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 pt-1">
|
||||
<div className="flex flex-col gap-3 pt-1">
|
||||
<div className="flex gap-1 border-b border-gray-200 pb-1">
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-6 w-20 shimmer-skeleton rounded" />
|
||||
<div className="h-6 w-20 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-1.5">
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-14 shimmer-skeleton rounded" />
|
||||
<div className="h-3 w-14 shimmer-skeleton rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -92,10 +93,15 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
|
||||
<Ind k="name" />
|
||||
</button>
|
||||
<span className="inline-flex items-center">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
{groupBy === "project" ? "Project" : groupBy === "person" ? "Person" : "Chapter"}
|
||||
<Ind k="name" />
|
||||
</button>
|
||||
{groupBy === "project" && <InfoTooltip content="Active projects with allocations in the current quarter. Short code shown before the name." />}
|
||||
{groupBy === "person" && <InfoTooltip content="Resources with active allocations in the current quarter." />}
|
||||
{groupBy === "chapter" && <InfoTooltip content="Organizational chapters (teams/departments) with active allocations in the current quarter." />}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
@@ -150,11 +156,21 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
{(() => {
|
||||
const ftes = row.requiredFTEs as unknown as number;
|
||||
return ftes > 0 ? (
|
||||
<span className={row.allocatedHours / 8 < ftes * 22 * 3 ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
if (ftes <= 0) return "—";
|
||||
const requiredHours = ftes * 22 * 3 * 8;
|
||||
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
|
||||
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
|
||||
const ringColor = isBelowTarget
|
||||
? "var(--color-red-500, #ef4444)"
|
||||
: "var(--color-green-500, #22c55e)";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
|
||||
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
</span>
|
||||
</span>
|
||||
) : "—";
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "../widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
ACTIVE: "bg-green-500",
|
||||
@@ -78,22 +80,28 @@ export function MyProjectsWidget({ config }: WidgetProps) {
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{favoriteProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10 flex items-center">
|
||||
Favorites
|
||||
<InfoTooltip content="Projects you have starred. Click the star icon on any project to add or remove it from your favorites." />
|
||||
</div>
|
||||
{favoriteProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} isFavorite onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
{favoriteProjects.map((p, i) => (
|
||||
<FadeIn key={p.id} delay={i * 0.03} direction="up">
|
||||
<ProjectRow project={p} isFavorite onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{responsibleProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10">
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10 flex items-center">
|
||||
Responsible
|
||||
<InfoTooltip content="Projects where you are listed as the responsible person. These are automatically shown based on your user name." />
|
||||
</div>
|
||||
{responsibleProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} isFavorite={false} onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
{responsibleProjects.map((p, i) => (
|
||||
<FadeIn key={p.id} delay={i * 0.03} direction="up">
|
||||
<ProjectRow project={p} isFavorite={false} onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -130,7 +138,7 @@ function ProjectRow({
|
||||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500 mr-1.5">{project.shortCode}</span>
|
||||
{project.name}
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase">{project.status}</span>
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase" title={`Project status: ${project.status}`}>{project.status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
|
||||
interface PeakTimesChartProps {
|
||||
chartData: Record<string, number | string>[];
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
const PeakTimesChart = dynamic(
|
||||
() => import("~/components/dashboard/widgets/PeakTimesChart.js"),
|
||||
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const granularity = (config.granularity as "week" | "month") || "month";
|
||||
@@ -35,16 +25,16 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 h-full pt-2">
|
||||
<div className="flex flex-col gap-3 h-full pt-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 flex-1 px-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-t"
|
||||
className="flex-1 shimmer-skeleton rounded-t"
|
||||
style={{ height: `${30 + Math.random() * 50}%` }}
|
||||
/>
|
||||
))}
|
||||
@@ -107,31 +97,7 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
<PeakTimesChart chartData={chartData} groups={groups} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
|
||||
function healthDot(value: number): string {
|
||||
if (value >= 70) return "bg-green-500";
|
||||
if (value >= 40) return "bg-amber-400";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
function scoreBadge(score: number): string {
|
||||
if (score >= 70) return "bg-green-100 text-green-700";
|
||||
if (score >= 40) return "bg-amber-100 text-amber-700";
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
|
||||
export function ProjectHealthWidget(_props: WidgetProps) {
|
||||
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
||||
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
||||
<div className="flex gap-1">
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-10 shimmer-skeleton rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No active projects found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Project</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500" title="Budget / Staffing / Timeline">
|
||||
B / S / T
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.shortCode} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[160px] truncate">
|
||||
<span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>
|
||||
{row.projectName}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
||||
title={`Budget: ${row.budgetHealth}%`}
|
||||
/>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
|
||||
title={`Staffing: ${row.staffingHealth}%`}
|
||||
/>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
|
||||
title={`Timeline: ${row.timelineHealth}%`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
|
||||
>
|
||||
{row.compositeScore}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user