chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("redirects unauthenticated users to sign-in", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/auth\/signin/);
|
||||
});
|
||||
|
||||
test("admin can sign in", 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(/\/resources/);
|
||||
});
|
||||
|
||||
test("shows error on invalid credentials", async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "wrong@example.com");
|
||||
await page.fill('input[type="password"]', "wrongpass");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator("text=Invalid email or password")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Projects", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/resources/);
|
||||
await page.goto("/projects");
|
||||
});
|
||||
|
||||
test("shows projects list", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("Projects");
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("project wizard — opens and shows step 1", async ({ page }) => {
|
||||
await page.locator("button", { hasText: "New Project" }).click();
|
||||
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
||||
});
|
||||
|
||||
test("project wizard — completes all 5 steps", async ({ page }) => {
|
||||
await page.locator("button", { hasText: "New Project" }).click();
|
||||
|
||||
// Step 1: Blueprint selection
|
||||
await expect(page.locator("text=Select Blueprint")).toBeVisible();
|
||||
// Select the first available blueprint
|
||||
const blueprintCard = page.locator("[data-blueprint-id]").first()
|
||||
.or(page.locator("button").filter({ hasText: /Blueprint|Production/ }).first());
|
||||
if (await blueprintCard.count() > 0) {
|
||||
await blueprintCard.click();
|
||||
} else {
|
||||
// Click next without blueprint if none shown
|
||||
const nextBtn = page.locator("button", { hasText: "Next" });
|
||||
await nextBtn.click();
|
||||
}
|
||||
|
||||
// Step 2: Timeline — set project dates
|
||||
await expect(page.locator("text=Timeline").or(page.locator("text=Project Dates"))).toBeVisible({ timeout: 5000 });
|
||||
const projectNameInput = page.locator('input[placeholder*="name"]').or(page.locator('input[name="name"]')).first();
|
||||
if (await projectNameInput.count() > 0) {
|
||||
await projectNameInput.fill(`E2E Test Project ${Date.now()}`);
|
||||
}
|
||||
await page.locator("button", { hasText: "Next" }).click();
|
||||
|
||||
// Step 3: Staffing demand
|
||||
await expect(
|
||||
page.locator("text=Staffing").or(page.locator("text=Demand").or(page.locator("text=Roles")))
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await page.locator("button", { hasText: "Next" }).click();
|
||||
|
||||
// Step 4: Suggestions / Assignment
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator("button", { hasText: "Next" }).click();
|
||||
|
||||
// Step 5: Review
|
||||
await page.waitForTimeout(500);
|
||||
const reviewOrFinish = page.locator("text=Review").or(page.locator("button", { hasText: /Create|Finish|Submit/ }));
|
||||
await expect(reviewOrFinish).toBeVisible({ timeout: 5000 });
|
||||
// Don't actually submit — just close
|
||||
const cancelBtn = page.locator("button", { hasText: /Cancel|Close/ }).first();
|
||||
if (await cancelBtn.count() > 0) {
|
||||
await cancelBtn.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Resources", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "manager@planarchy.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/resources/);
|
||||
});
|
||||
|
||||
test("shows resources list", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("Resources");
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can search resources", async ({ page }) => {
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill("EMP-001");
|
||||
await page.waitForTimeout(500);
|
||||
// Should show filtered results
|
||||
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Timeline", () => {
|
||||
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(/\/resources/);
|
||||
await page.goto("/timeline");
|
||||
});
|
||||
|
||||
test("loads and displays the timeline", async ({ page }) => {
|
||||
await expect(page.locator("text=Resource view")).toBeVisible();
|
||||
await expect(page.locator("text=Project view")).toBeVisible();
|
||||
// Timeline canvas should be visible
|
||||
await expect(page.locator(".overflow-auto")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch between resource and project view", async ({ page }) => {
|
||||
await page.click("text=Project view");
|
||||
await expect(page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/"))).toBeVisible();
|
||||
await page.click("text=Resource view");
|
||||
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate forward and back", async ({ page }) => {
|
||||
const todayBtn = page.locator("button", { hasText: "Today" });
|
||||
await expect(todayBtn).toBeVisible();
|
||||
await page.locator("button", { hasText: "›" }).click();
|
||||
await page.locator("button", { hasText: "‹" }).click();
|
||||
await todayBtn.click();
|
||||
});
|
||||
|
||||
test("filter panel opens and closes", async ({ page }) => {
|
||||
await page.locator("button", { hasText: "Filter" }).click();
|
||||
await expect(page.locator("text=Chapters")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("shows placeholder bars for unassigned allocations", async ({ page }) => {
|
||||
// Filter to show placeholders (enabled by default)
|
||||
// The timeline should have at least one dashed placeholder bar from seed data
|
||||
await page.waitForSelector(".overflow-auto", { state: "visible" });
|
||||
// Check that the timeline loaded (resource rows or empty state visible)
|
||||
await expect(
|
||||
page.locator("text=resources").or(page.locator("text=No allocations"))
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a placeholder opens the fill placeholder modal", async ({ page }) => {
|
||||
// Wait for timeline to load
|
||||
await page.waitForSelector(".overflow-auto");
|
||||
await page.waitForTimeout(1000); // let tRPC queries settle
|
||||
|
||||
// Try to find and click a placeholder bar (dashed border style)
|
||||
const placeholderBar = page.locator("[style*='dashed']").first();
|
||||
if (await placeholderBar.count() > 0) {
|
||||
await placeholderBar.click();
|
||||
await expect(page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource"))).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@planarchy/api",
|
||||
"@planarchy/db",
|
||||
"@planarchy/engine",
|
||||
"@planarchy/shared",
|
||||
"@planarchy/staffing",
|
||||
"@planarchy/ui",
|
||||
],
|
||||
typedRoutes: true,
|
||||
// Webpack config (used by `next build` and `next dev` without --turbo)
|
||||
webpack(config) {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"~": path.resolve(__dirname, "src"),
|
||||
};
|
||||
// Resolve .js imports to .ts/.tsx (TypeScript ESM convention)
|
||||
config.resolve.extensionAlias = {
|
||||
...config.resolve.extensionAlias,
|
||||
".js": [".ts", ".tsx", ".js"],
|
||||
".jsx": [".tsx", ".jsx"],
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@planarchy/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3100",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3100",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@planarchy/application": "workspace:*",
|
||||
"@planarchy/api": "workspace:*",
|
||||
"@planarchy/db": "workspace:*",
|
||||
"@planarchy/engine": "workspace:*",
|
||||
"@planarchy/shared": "workspace:*",
|
||||
"@planarchy/ui": "workspace:*",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@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",
|
||||
"next": "^15.1.7",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@planarchy/tsconfig": "workspace:*",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-grid-layout": "^2.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env["CI"],
|
||||
retries: process.env["CI"] ? 2 : 0,
|
||||
...(process.env["CI"] ? { workers: 1 } : {}),
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:3100",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3100",
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BlueprintsClient } from "~/components/blueprints/BlueprintsClient.js";
|
||||
|
||||
export default function BlueprintsPage() {
|
||||
return <BlueprintsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ClientsAdminClient } from "~/components/admin/ClientsAdminClient.js";
|
||||
|
||||
export default function ClientsPage() {
|
||||
return <ClientsAdminClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CountriesClient } from "~/components/admin/CountriesClient.js";
|
||||
|
||||
export default function CountriesPage() {
|
||||
return <CountriesClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EffortRulesClient } from "~/components/admin/EffortRulesClient.js";
|
||||
|
||||
export default function EffortRulesPage() {
|
||||
return <EffortRulesClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExperienceMultipliersClient } from "~/components/admin/ExperienceMultipliersClient.js";
|
||||
|
||||
export default function ExperienceMultipliersPage() {
|
||||
return <ExperienceMultipliersClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ManagementLevelsClient } from "~/components/admin/ManagementLevelsClient.js";
|
||||
|
||||
export default function ManagementLevelsPage() {
|
||||
return <ManagementLevelsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrgUnitsClient } from "~/components/admin/OrgUnitsClient.js";
|
||||
|
||||
export default function OrgUnitsPage() {
|
||||
return <OrgUnitsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RateCardsClient } from "~/components/admin/RateCardsClient.js";
|
||||
|
||||
export default function RateCardsPage() {
|
||||
return <RateCardsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SystemSettingsClient } from "~/components/admin/SystemSettingsClient.js";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <SystemSettingsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BatchSkillImport } from "~/components/admin/BatchSkillImport.js";
|
||||
|
||||
export default function BatchSkillImportPage() {
|
||||
return <BatchSkillImport />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UsersClient } from "~/components/admin/UsersClient.js";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <UsersClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UtilizationCategoriesClient } from "~/components/admin/UtilizationCategoriesClient.js";
|
||||
|
||||
export default function UtilizationCategoriesPage() {
|
||||
return <UtilizationCategoriesClient />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||
|
||||
export const metadata = { title: "Vacation Management — Planarchy" };
|
||||
|
||||
export default function AdminVacationsPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
|
||||
</div>
|
||||
<PublicHolidayBatch />
|
||||
<EntitlementManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export default function AllocationsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AllocationsClient } from "~/components/allocations/AllocationsClient.js";
|
||||
|
||||
export default function AllocationsPage() {
|
||||
return <AllocationsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js";
|
||||
|
||||
export default function SkillsAnalyticsPage() {
|
||||
return <SkillsAnalytics />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "~/components/dashboard/DashboardClient.js";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { AppRouter } from "@planarchy/api/router";
|
||||
import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { clsx } from "clsx";
|
||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
type EstimateListItem = RouterOutput["estimate"]["list"][number];
|
||||
type EstimateDetail = RouterOutput["estimate"]["getById"];
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
function formatMoney(cents: number | null | undefined, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format((cents ?? 0) / 100);
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
|
||||
if (metric.valueCents != null) {
|
||||
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||
}
|
||||
if (metric.key === "margin_percent") {
|
||||
return `${metric.valueDecimal.toFixed(0)}%`;
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
|
||||
}
|
||||
|
||||
function getLatestVersion(estimate: EstimateDetail | null | undefined) {
|
||||
if (!estimate) return null;
|
||||
return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null;
|
||||
}
|
||||
|
||||
function EstimateDetailPanel({
|
||||
estimate,
|
||||
onClone,
|
||||
cloning,
|
||||
}: {
|
||||
estimate: EstimateDetail;
|
||||
onClone?: (id: string) => void;
|
||||
cloning?: boolean;
|
||||
}) {
|
||||
const latestVersion = getLatestVersion(estimate);
|
||||
const latestMetrics = latestVersion?.metrics ?? [];
|
||||
|
||||
return (
|
||||
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<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</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-gray-900">{estimate.name}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}
|
||||
</p>
|
||||
</div>
|
||||
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
{onClone && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={cloning}
|
||||
onClick={() => onClone(estimate.id)}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{cloning ? "Cloning..." : "Clone"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Version {latestVersion.versionNumber}
|
||||
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
|
||||
</span>
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||
{latestVersion.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{latestMetrics.length > 0 && (
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{latestMetrics.map((metric) => (
|
||||
<div key={metric.id} className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900">{formatMetricValue(metric)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestVersion.notes && (
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
|
||||
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Scope items</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">
|
||||
No scope rows captured yet.
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.scopeItems.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-gray-900">{item.name}</p>
|
||||
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
||||
</div>
|
||||
{item.description && <p className="mt-1 text-sm text-gray-600">{item.description}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Demand lines</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">
|
||||
No staffing demand captured yet.
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.demandLines.map((line) => (
|
||||
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-gray-900">{line.name}</p>
|
||||
<p className="text-sm font-medium text-gray-600">{line.hours.toFixed(1)} h</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
|
||||
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
|
||||
{line.chapter && <span>{line.chapter}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
|
||||
No versions available for this estimate yet.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function EstimateCard({
|
||||
estimate,
|
||||
active,
|
||||
onSelect,
|
||||
canInspect,
|
||||
}: {
|
||||
estimate: EstimateListItem;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
canInspect: boolean;
|
||||
}) {
|
||||
const latestVersion = estimate.versions[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={!canInspect}
|
||||
className={clsx(
|
||||
"w-full rounded-3xl border p-5 text-left transition",
|
||||
active ? "border-brand-500 bg-brand-50 shadow-sm" : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm",
|
||||
!canInspect && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||
{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">
|
||||
{estimate.project.shortCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900">{estimate.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{estimate.project ? estimate.project.name : "No linked project"}
|
||||
</p>
|
||||
</div>
|
||||
{latestVersion && (
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="mt-1 text-sm text-gray-700">{estimate.opportunityId ?? "Not set"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||
<p className="mt-1 text-sm text-gray-700">{formatDateLong(estimate.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canInspect && (
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
Detailed financial breakdown is limited to manager and controller roles.
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EstimatesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<EstimateStatus | "">("");
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [selectedEstimateId, setSelectedEstimateId] = useState<string | null>(null);
|
||||
const { canEdit, canViewCosts } = usePermissions();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const cloneMutation = trpc.estimate.clone.useMutation({
|
||||
onSuccess: (cloned) => {
|
||||
void utils.estimate.list.invalidate();
|
||||
setSelectedEstimateId(cloned.id);
|
||||
},
|
||||
});
|
||||
|
||||
const listQuery = trpc.estimate.list.useQuery(
|
||||
{
|
||||
query: search || undefined,
|
||||
status: status || undefined,
|
||||
},
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
const detailQuery = trpc.estimate.getById.useQuery(
|
||||
{ id: selectedEstimateId ?? "" },
|
||||
{
|
||||
enabled: canViewCosts && !!selectedEstimateId,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
|
||||
const estimates = listQuery.data ?? [];
|
||||
|
||||
const selectedEstimate = useMemo(() => {
|
||||
if (!canViewCosts) return null;
|
||||
return detailQuery.data ?? null;
|
||||
}, [canViewCosts, detailQuery.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
|
||||
<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">Estimating</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900">Browser-native estimate workspace</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-gray-600">
|
||||
Build structured estimates from live projects, resources, and role data instead of maintaining a disconnected spreadsheet.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||
>
|
||||
New Estimate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 lg:grid-cols-[minmax(0,1fr),220px]">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search by estimate or opportunity"
|
||||
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none ring-0 transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as EstimateStatus | "")}
|
||||
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Loading estimates...
|
||||
</div>
|
||||
) : estimates.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center">
|
||||
<p className="text-base font-medium text-gray-700">No estimates yet</p>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Start with the wizard to create a connected estimate from Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(320px,0.95fr)]">
|
||||
<div className="space-y-4">
|
||||
{estimates.map((estimate) => (
|
||||
<EstimateCard
|
||||
key={estimate.id}
|
||||
estimate={estimate}
|
||||
active={estimate.id === selectedEstimateId}
|
||||
canInspect={canViewCosts}
|
||||
onSelect={() => {
|
||||
if (!canViewCosts) return;
|
||||
setSelectedEstimateId((current) => (current === estimate.id ? current : estimate.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{canViewCosts ? (
|
||||
selectedEstimate ? (
|
||||
<EstimateDetailPanel
|
||||
estimate={selectedEstimate}
|
||||
{...(canEdit ? { onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Select an estimate to inspect the current version, demand lines, and summary metrics.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Your role can access the estimate list, but not the detailed financial breakdown.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wizardOpen && <EstimateWizard onClose={() => setWizardOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EstimateWorkspaceClient } from "~/components/estimates/EstimateWorkspaceClient.js";
|
||||
|
||||
interface EstimateWorkspacePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EstimateWorkspacePage({ params }: EstimateWorkspacePageProps) {
|
||||
const { id } = await params;
|
||||
return <EstimateWorkspaceClient estimateId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EstimatesClient } from "./EstimatesClient.js";
|
||||
|
||||
export default function EstimatesPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EstimatesClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppShell } from "~/components/layout/AppShell.js";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const userRole = (session.user as { role?: string }).role ?? "USER";
|
||||
return <AppShell userRole={userRole}>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +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="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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { formatDate } 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 { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ProjectModal } from "~/components/projects/ProjectModal.js";
|
||||
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const ORDER_TYPE_COLORS: Record<string, string> = {
|
||||
BD: "bg-purple-100 text-purple-700",
|
||||
CHARGEABLE: "bg-green-100 text-green-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const ALL_STATUSES = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
const ALL_ORDER_TYPES = [
|
||||
{ value: "BD", label: "BD" },
|
||||
{ value: "CHARGEABLE", label: "Chargeable" },
|
||||
{ value: "INTERNAL", label: "Internal" },
|
||||
{ value: "OVERHEAD", label: "Overhead" },
|
||||
] as const;
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
|
||||
if (budgetCents === 0) {
|
||||
return <div className="text-xs text-gray-400">No budget</div>;
|
||||
}
|
||||
const cappedPercent = Math.min(utilizationPercent, 100);
|
||||
let barColor = "bg-green-500";
|
||||
if (utilizationPercent > 95) barColor = "bg-red-500";
|
||||
else if (utilizationPercent > 85) barColor = "bg-orange-500";
|
||||
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 min-w-[80px]">
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden w-full">
|
||||
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateStatus = trpc.project.updateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-80",
|
||||
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
|
||||
)}
|
||||
title="Click to change status"
|
||||
>
|
||||
{project.status}
|
||||
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[130px]">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
disabled={s.value === project.status || updateStatus.isPending}
|
||||
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
|
||||
className={clsx(
|
||||
"w-full text-left px-3 py-1.5 text-xs transition-colors",
|
||||
s.value === project.status
|
||||
? "font-semibold text-gray-400 cursor-default"
|
||||
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
orderType: string;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
budgetCents: number;
|
||||
winProbability: number;
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
utilizationPercent: number;
|
||||
dynamicFields?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function ProjectsClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts } = usePermissions();
|
||||
|
||||
const batchUpdateStatus = trpc.project.batchUpdateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Custom field columns from global blueprints ──────────────────────────
|
||||
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
|
||||
{ target: BlueprintTarget.PROJECT },
|
||||
{ staleTime: 300_000 },
|
||||
);
|
||||
const customColumns = useMemo<ColumnDef[]>(
|
||||
() =>
|
||||
(globalFieldDefs ?? [])
|
||||
.filter((f) => f.showInList)
|
||||
.map((f) => ({
|
||||
key: `custom_${f.key}`,
|
||||
label: f.label,
|
||||
defaultVisible: false,
|
||||
hideable: true,
|
||||
isCustom: true,
|
||||
fieldType: f.type as string,
|
||||
})),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
// Filter out budget column if user cannot view costs
|
||||
const baseColumns = useMemo<ColumnDef[]>(
|
||||
() => (canViewCosts ? PROJECT_COLUMNS : PROJECT_COLUMNS.filter((c) => c.key !== "budget")),
|
||||
[canViewCosts],
|
||||
);
|
||||
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||
"projects",
|
||||
baseColumns,
|
||||
customColumns,
|
||||
);
|
||||
const defaultKeys = useMemo(
|
||||
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||
[baseColumns],
|
||||
);
|
||||
|
||||
// ─── Infinite query (cursor-based) ────────────────────────────────────────
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = trpc.project.listWithCosts.useInfiniteQuery(
|
||||
{
|
||||
search: search || undefined,
|
||||
status: (statusFilter as ProjectStatus) || undefined,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev) => prev,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
|
||||
const allProjects = useMemo(
|
||||
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
|
||||
[data],
|
||||
);
|
||||
|
||||
// Client-side orderType filter
|
||||
const filteredProjects = useMemo(
|
||||
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
|
||||
[allProjects, orderTypeFilter],
|
||||
);
|
||||
|
||||
// ─── Sort + row order ─────────────────────────────────────────────────────
|
||||
const viewPrefs = useViewPrefs("projects");
|
||||
const { sorted, sortField, sortDir, toggle, reset } = useTableSort(filteredProjects, {
|
||||
initialField: viewPrefs.savedSort?.field ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
const rowDragRef = useRef<string | null>(null);
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, statusFilter, orderTypeFilter]);
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function openNewModal() { setEditingProject(null); setModalOpen(true); }
|
||||
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
|
||||
function closeModal() { setModalOpen(false); setEditingProject(null); }
|
||||
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
|
||||
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
|
||||
];
|
||||
|
||||
// ─── Cell renderer ────────────────────────────────────────────────────────
|
||||
function renderCell(col: ColumnDef, project: ProjectRow) {
|
||||
const dynFields = (project.dynamicFields ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (col.isCustom) {
|
||||
const fieldKey = col.key.replace(/^custom_/, "");
|
||||
const val = dynFields[fieldKey];
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{val != null ? String(val) : "—"}</td>;
|
||||
}
|
||||
|
||||
switch (col.key) {
|
||||
case "shortCode":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900">{project.shortCode}</td>;
|
||||
case "name":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 max-w-xs truncate">
|
||||
<Link href={`/projects/${project.id}`} className="hover:text-brand-600 hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
</td>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<StatusDropdown
|
||||
project={project}
|
||||
isOpen={openStatusProjectId === project.id}
|
||||
onOpen={() => setOpenStatusProjectId(project.id)}
|
||||
onClose={() => setOpenStatusProjectId(null)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "orderType":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case "dates":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(project.startDate)} – {formatDate(project.endDate)}
|
||||
</td>
|
||||
);
|
||||
case "budget":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 min-w-[120px]">
|
||||
<div className="text-sm text-gray-900 mb-0.5">
|
||||
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
</td>
|
||||
);
|
||||
case "allocations":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
|
||||
</td>
|
||||
);
|
||||
case "responsible":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">—</td>;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header renderer ──────────────────────────────────────────────────────
|
||||
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
|
||||
function renderHeader(col: ColumnDef) {
|
||||
if (SORTABLE_PROJECT_COLS.has(col.key)) {
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
field={col.key}
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
{hasNextPage ? "+" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
New Project Wizard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewModal}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={orderTypeFilter}
|
||||
onChange={(e) => setOrderTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{ALL_ORDER_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
{isCustomOrder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{/* Filter chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{/* Drag handle column */}
|
||||
<th className="w-8 px-2" />
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(projectIds)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
|
||||
}}
|
||||
onChange={() => selection.toggleAll(projectIds)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map(renderHeader)}
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{projects.map((project) => {
|
||||
const isSelected = selection.selectedIds.has(project.id);
|
||||
return (
|
||||
<DraggableTableRow
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => reorder(draggedId, project.id)}
|
||||
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.toggle(project.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</td>
|
||||
{visibleColumns.map((col) => renderCell(col, project))}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(project as unknown as Project)}
|
||||
className="text-xs text-gray-600 hover:text-gray-900 hover:underline font-medium transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
|
||||
View →
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</DraggableTableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No projects found.{" "}
|
||||
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
|
||||
Create your first project.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={handleFetchNext}
|
||||
isLoading={isFetchingNextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
||||
<div className="bg-white rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Set status for {selection.count} projects</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
|
||||
setBatchStatusPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm batch status change */}
|
||||
{confirmBatchStatus && (
|
||||
<ConfirmDialog
|
||||
title="Update Project Status"
|
||||
message={`Set ${confirmBatchStatus.ids.length} project${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
if (confirmBatchStatus) {
|
||||
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
}
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchStatus(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{ label: "Set Status…", onClick: () => setBatchStatusPicker(true) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && <ProjectModal project={editingProject} onClose={closeModal} />}
|
||||
|
||||
{/* Wizard */}
|
||||
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import Link from "next/link";
|
||||
import { createCaller } from "~/server/trpc.js";
|
||||
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
interface ProjectDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const ORDER_TYPE_COLORS: Record<string, string> = {
|
||||
BD: "bg-purple-100 text-purple-700",
|
||||
CHARGEABLE: "bg-green-100 text-green-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const ALLOC_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
PROPOSED: "bg-yellow-100 text-yellow-700",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const trpc = await createCaller();
|
||||
|
||||
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
|
||||
try {
|
||||
project = await trpc.project.getById({ id });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const activeAssignments = project.assignments.filter((assignment) => assignment.status !== "CANCELLED");
|
||||
const activeDemands = project.demands.filter((demand) => demand.status !== "CANCELLED");
|
||||
const requestedSeats = activeDemands.reduce((sum, demand) => sum + demand.requestedHeadcount, 0);
|
||||
const unfilledSeats = activeDemands.reduce((sum, demand) => sum + demand.unfilledHeadcount, 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/projects"
|
||||
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 Projects
|
||||
</Link>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-mono text-sm font-medium text-gray-500">{project.shortCode}</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 flex-shrink-0">
|
||||
<div className="font-medium text-gray-800">
|
||||
{formatDate(project.startDate)}
|
||||
{" — "}
|
||||
{formatDate(project.endDate)}
|
||||
</div>
|
||||
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Assignments</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>
|
||||
<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>
|
||||
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Budget status card (client component) */}
|
||||
<BudgetStatusCard projectId={project.id} />
|
||||
|
||||
{/* Assignments table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Assignments ({project.assignments.length})
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role <InfoTooltip content="Role this allocation was created for." />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Period <InfoTooltip content="Start and end date of the allocation." />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Hours/Day <InfoTooltip content="Planned working hours per calendar day." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Daily Cost <InfoTooltip content="Resource LCR × hours per day." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status <InfoTooltip content="PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed." />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{project.assignments.map((assignment) => (
|
||||
<tr key={assignment.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{assignment.resource?.displayName ?? "—"}
|
||||
{assignment.resource?.eid && (
|
||||
<span className="ml-1.5 text-xs text-gray-400 font-mono">{assignment.resource.eid}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{assignment.role || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(assignment.startDate)}
|
||||
{" → "}
|
||||
{formatDate(assignment.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{assignment.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[assignment.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{assignment.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{project.assignments.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No assignments for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open demands table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Open Demands ({project.demands.length})
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Period
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Requested
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Unfilled
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Hours/Day
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{project.demands.map((demand) => (
|
||||
<tr key={demand.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(demand.startDate)}
|
||||
{" → "}
|
||||
{formatDate(demand.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.requestedHeadcount}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.unfilledHeadcount}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{demand.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{project.demands.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No open demands for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export default function ProjectsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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 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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ProjectsClient } from "./ProjectsClient.js";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ProjectsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChargeabilityReportClient } from "~/components/reports/ChargeabilityReportClient.js";
|
||||
|
||||
export default function ChargeabilityReportPage() {
|
||||
return <ChargeabilityReportClient />;
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { RESOURCE_COLUMNS } from "@planarchy/shared";
|
||||
import { BlueprintTarget } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||
import { ImportModal } from "~/components/resources/ImportModal.js";
|
||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { CustomFieldFilterBar } from "~/components/ui/CustomFieldFilterBar.js";
|
||||
import { useFilters } from "~/hooks/useFilters.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
|
||||
type ModalState =
|
||||
| { type: "closed" }
|
||||
| { type: "create" }
|
||||
| { type: "edit"; resource: Resource }
|
||||
| { type: "import" }
|
||||
| { type: "bulkEdit" };
|
||||
|
||||
type ConfirmState =
|
||||
| { type: "closed" }
|
||||
| { type: "batchDeactivate"; ids: string[] }
|
||||
| { type: "deactivate"; resource: Resource };
|
||||
|
||||
type ActiveFilter = "active" | "inactive" | "all";
|
||||
type ResourceListPage = {
|
||||
resources: Resource[];
|
||||
total: number;
|
||||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
export function ResourcesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [chapterFilter, setChapterFilter] = useState("");
|
||||
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active");
|
||||
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
||||
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewScores, canViewCosts } = usePermissions();
|
||||
const { customFieldFilters, setCustomFieldFilter, clearFilters: clearCustomFilters } = useFilters();
|
||||
|
||||
// ─── Custom field columns from global blueprints ──────────────────────────
|
||||
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
|
||||
{ target: BlueprintTarget.RESOURCE },
|
||||
{ staleTime: 300_000 },
|
||||
);
|
||||
const customColumns = useMemo(
|
||||
() =>
|
||||
(globalFieldDefs ?? [])
|
||||
.filter((f) => f.showInList)
|
||||
.map((f) => ({
|
||||
key: `custom_${f.key}`,
|
||||
label: f.label,
|
||||
defaultVisible: false,
|
||||
hideable: true,
|
||||
isCustom: true,
|
||||
fieldType: f.type as string,
|
||||
})),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
const filterableFields = useMemo(
|
||||
() => (globalFieldDefs ?? []).filter((f) => f.isFilterable),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||
"resources",
|
||||
RESOURCE_COLUMNS,
|
||||
customColumns,
|
||||
);
|
||||
const defaultKeys = useMemo(
|
||||
() => RESOURCE_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||
[],
|
||||
);
|
||||
|
||||
// ─── Infinite query (cursor-based) ────────────────────────────────────────
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
|
||||
} = (trpc.resource.list.useInfiniteQuery as any)(
|
||||
{
|
||||
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
||||
search: search || undefined,
|
||||
chapter: chapterFilter || undefined,
|
||||
includeRoles: true,
|
||||
limit: 50,
|
||||
...(customFieldFilters.length > 0 ? { customFieldFilters } : {}),
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage: ResourceListPage) => lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev: { pages: ResourceListPage[] } | undefined) => prev,
|
||||
staleTime: 20_000,
|
||||
},
|
||||
) as {
|
||||
data:
|
||||
| {
|
||||
pages: ResourceListPage[];
|
||||
}
|
||||
| undefined;
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
hasNextPage: boolean | undefined;
|
||||
};
|
||||
|
||||
const resources = useMemo(
|
||||
() => (data?.pages.flatMap((p) => p.resources) ?? []) as unknown as Resource[],
|
||||
[data],
|
||||
);
|
||||
const total = data?.pages[0]?.total ?? 0;
|
||||
|
||||
// ─── Sort + row order (per-user persistence) ──────────────────────────────
|
||||
const viewPrefs = useViewPrefs("resources");
|
||||
const { sorted, sortField, sortDir, toggle, reset } = useTableSort<Resource>(resources, {
|
||||
initialField: viewPrefs.savedSort?.field ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: displayedResources, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
const rowDragRef = useRef<string | null>(null);
|
||||
const resourceIds: string[] = displayedResources.map((r) => r.id);
|
||||
|
||||
// Performance note: cursor-based infinite scroll (50 rows/page) keeps DOM nodes bounded.
|
||||
// True virtualizer is not needed for typical resource counts (<500).
|
||||
|
||||
// ─── Chargeability stats ──────────────────────────────────────────────────
|
||||
const { data: chargeabilityData } = trpc.resource.getChargeabilityStats.useQuery(
|
||||
{},
|
||||
{ enabled: canViewCosts, placeholderData: (prev) => prev, staleTime: 60_000 },
|
||||
);
|
||||
const chargeabilityMap = useMemo(
|
||||
() => new Map((chargeabilityData ?? []).map((s) => [s.id, s])),
|
||||
[chargeabilityData],
|
||||
);
|
||||
|
||||
// ─── Chapters filter ──────────────────────────────────────────────────────
|
||||
const { data: chapterData } = trpc.resource.chapters.useQuery(
|
||||
undefined,
|
||||
{ placeholderData: (prev) => prev, staleTime: 60_000 },
|
||||
);
|
||||
const chapters = chapterData ?? [];
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
||||
onSuccess: async () => { await utils.resource.list.invalidate(); },
|
||||
});
|
||||
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, chapterFilter, isActiveFilter]);
|
||||
|
||||
function closeModal() { setModal({ type: "closed" }); }
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirm.type === "deactivate") {
|
||||
deactivateMutation.mutate({ id: confirm.resource.id });
|
||||
} else if (confirm.type === "batchDeactivate") {
|
||||
batchDeactivateMutation.mutate({ ids: confirm.ids });
|
||||
}
|
||||
setConfirm({ type: "closed" });
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
setChapterFilter("");
|
||||
setIsActiveFilter("active");
|
||||
clearCustomFilters();
|
||||
}
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(chapterFilter ? [{ label: `Chapter: ${chapterFilter}`, onRemove: () => setChapterFilter("") }] : []),
|
||||
...(isActiveFilter !== "active" ? [{ label: isActiveFilter === "all" ? "Showing all" : "Inactive only", onRemove: () => setIsActiveFilter("active") }] : []),
|
||||
...customFieldFilters.map((f) => ({
|
||||
label: `${f.key}: ${f.value}`,
|
||||
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resources</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">{total} resource{total !== 1 ? "s" : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModal({ type: "import" })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModal({ type: "create" })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
New Resource
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters + Column toggle */}
|
||||
<FilterBar>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name, EID, email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapterFilter}
|
||||
onChange={(e) => setChapterFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={isActiveFilter}
|
||||
onChange={(e) => setIsActiveFilter(e.target.value as ActiveFilter)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="active">Active only</option>
|
||||
<option value="inactive">Inactive only</option>
|
||||
<option value="all">All resources</option>
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
{isCustomOrder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{filterableFields.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<CustomFieldFilterBar
|
||||
filterableFields={filterableFields}
|
||||
activeFilters={customFieldFilters}
|
||||
onSetFilter={setCustomFieldFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{isLoading && resources.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-400 text-sm animate-pulse">Loading resources…</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{/* Drag handle column */}
|
||||
<th className="w-8 px-2" />
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(resourceIds)}
|
||||
ref={(el) => { if (el) el.indeterminate = selection.isIndeterminate(resourceIds); }}
|
||||
onChange={() => selection.toggleAll(resourceIds)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
if (col.isCustom) {
|
||||
return (
|
||||
<th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
switch (col.key) {
|
||||
case "eid":
|
||||
return <SortableColumnHeader key={col.key} label="EID" field="eid" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Unique employee identifier used across all Planarchy records." />;
|
||||
case "displayName":
|
||||
return <SortableColumnHeader key={col.key} label="Name / Email" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
|
||||
case "chapter":
|
||||
return <SortableColumnHeader key={col.key} label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
|
||||
case "lcr":
|
||||
return <SortableColumnHeader key={col.key} label="LCR (€/h)" field="lcrCents" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Labour Cost Rate — the resource's hourly cost in EUR. Used to calculate project budgets (LCR × hours/day × working days)." />;
|
||||
case "chargeability":
|
||||
return <SortableColumnHeader key={col.key} label="Chargeability (actual)" field="chargeabilityTarget" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Actual = CONFIRMED+ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100. Expected (in parentheses) includes DRAFT projects. Target is the management-set goal." tooltipWidth="w-80" />;
|
||||
case "valueScore":
|
||||
return canViewScores
|
||||
? <SortableColumnHeader key={col.key} label="Score" field="valueScore" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Composite price/quality score 0–100. Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%. Recompute in Admin → Settings." tooltipWidth="w-72" />
|
||||
: null;
|
||||
case "roles":
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles <InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." /></th>;
|
||||
case "isActive":
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Skills <InfoTooltip content="Skills from the resource's skill matrix. Shows first 3; hover the +N badge for more." /></th>;
|
||||
default:
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>;
|
||||
}
|
||||
})}
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displayedResources.map((resource) => {
|
||||
const skills = resource.skills as unknown as SkillEntry[];
|
||||
const isSelected = selection.selectedIds.has(resource.id);
|
||||
const isDeactivating =
|
||||
deactivateMutation.isPending &&
|
||||
(deactivateMutation.variables as { id: string } | undefined)?.id === resource.id;
|
||||
const dynFields = (resource as unknown as { dynamicFields?: Record<string, unknown> }).dynamicFields ?? {};
|
||||
|
||||
return (
|
||||
<DraggableTableRow
|
||||
key={resource.id}
|
||||
id={resource.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => reorder(draggedId, resource.id)}
|
||||
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={isSelected} onChange={() => selection.toggle(resource.id)} className="rounded border-gray-300" />
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
if (col.isCustom) {
|
||||
const fieldKey = col.key.replace(/^custom_/, "");
|
||||
const val = dynFields[fieldKey];
|
||||
return <td key={col.key} className="px-3 py-3 text-sm text-gray-700">{val != null ? String(val) : "—"}</td>;
|
||||
}
|
||||
switch (col.key) {
|
||||
case "eid":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono text-gray-600">{resource.eid}</td>;
|
||||
case "displayName":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<Link href={`/resources/${resource.id}`} className="text-sm font-medium text-gray-900 hover:text-brand-600 hover:underline transition-colors">{resource.displayName}</Link>
|
||||
<div className="text-xs text-gray-500">{resource.email}</div>
|
||||
</td>
|
||||
);
|
||||
case "chapter":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{resource.chapter ?? "—"}</td>;
|
||||
case "lcr":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900">{(resource.lcrCents / 100).toFixed(0)} {resource.currency}</td>;
|
||||
case "chargeability": {
|
||||
if (!canViewCosts) return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">{resource.chargeabilityTarget}%</td>;
|
||||
const stats = chargeabilityMap.get(resource.id);
|
||||
const actual = stats?.actualChargeability;
|
||||
const expected = stats?.expectedChargeability;
|
||||
const target = resource.chargeabilityTarget;
|
||||
const color = actual == null ? "text-gray-400" : actual >= target ? "text-green-700" : actual >= target - 20 ? "text-amber-600" : "text-red-600";
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className={`font-medium ${color}`}>{actual != null ? `${actual}%` : "—"}</span>
|
||||
{expected != null && expected !== actual && <span className="text-xs text-gray-400 ml-1">({expected}% exp.)</span>}
|
||||
<div className="text-xs text-gray-400">Target: {target}%</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "valueScore": {
|
||||
if (!canViewScores) return null;
|
||||
const score = (resource as unknown as { valueScore?: number | null }).valueScore;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
{score != null ? (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700" : score >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>{score}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "roles": {
|
||||
const rr = ((resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[] }).resourceRoles ?? []);
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rr.map((r) => (
|
||||
<span key={r.role.id} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full font-medium" style={{ backgroundColor: `${r.role.color ?? "#6366f1"}22`, color: r.role.color ?? "#6366f1" }}>
|
||||
{r.isPrimary && <span className="text-[10px]">★</span>}
|
||||
{r.role.name}
|
||||
</span>
|
||||
))}
|
||||
{rr.length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "isActive":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skills.slice(0, 3).map((s) => (
|
||||
<span key={s.skill} className="inline-block px-2 py-0.5 text-xs bg-brand-50 text-brand-700 rounded-full">{s.skill}</span>
|
||||
))}
|
||||
{skills.length > 3 && <span className="text-xs text-gray-400">+{skills.length - 3}</span>}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">—</td>;
|
||||
}
|
||||
})}
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button type="button" onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource })} className="text-xs font-medium text-brand-600 hover:text-brand-800 transition-colors mr-3">Edit</button>
|
||||
<button type="button" onClick={() => setConfirm({ type: "deactivate", resource: resource as unknown as Resource })} disabled={isDeactivating} className="text-xs font-medium text-red-600 hover:text-red-800 transition-colors disabled:opacity-50">{isDeactivating ? "Deactivating…" : "Deactivate"}</button>
|
||||
</td>
|
||||
</DraggableTableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{displayedResources.length === 0 && !isLoading && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No resources found.</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
...(filterableFields.length > 0 ? [{
|
||||
label: "Edit Custom Fields",
|
||||
variant: "default" as const,
|
||||
onClick: () => setModal({ type: "bulkEdit" }),
|
||||
disabled: false,
|
||||
}] : []),
|
||||
{
|
||||
label: `Deactivate ${selection.count > 0 ? `(${selection.count})` : ""}`,
|
||||
variant: "danger" as const,
|
||||
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
|
||||
disabled: batchDeactivateMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
|
||||
{modal.type === "edit" && <ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />}
|
||||
{modal.type === "import" && <ImportModal onClose={closeModal} />}
|
||||
{modal.type === "bulkEdit" && (
|
||||
<BulkEditModal
|
||||
selectedIds={selection.selectedArray}
|
||||
fieldDefs={filterableFields}
|
||||
onClose={closeModal}
|
||||
onSuccess={selection.clear}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm.type === "deactivate" && (
|
||||
<ConfirmDialog title="Deactivate Resource" message={`Deactivate "${confirm.resource.displayName}" (${confirm.resource.eid})? This will remove them from the active resource list.`} confirmLabel="Deactivate" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
|
||||
)}
|
||||
{confirm.type === "batchDeactivate" && (
|
||||
<ConfirmDialog title="Deactivate Resources" message={`Deactivate ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""}?`} confirmLabel="Deactivate All" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { createCaller } from "~/server/trpc.js";
|
||||
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const trpc = await createCaller();
|
||||
const resource = await trpc.resource.getById({ id });
|
||||
return { title: `${resource.displayName} — Resources | Planarchy` };
|
||||
} catch {
|
||||
return { title: "Resource — Planarchy" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ResourceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <ResourceDetail resourceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export default function ResourcesLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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 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>
|
||||
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { ResourcesClient } from "./ResourcesClient.js";
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResourcesClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RolesClient } from "~/components/roles/RolesClient.js";
|
||||
|
||||
export default function RolesPage() {
|
||||
return <RolesClient />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StaffingPanel } from "~/components/staffing/StaffingPanel.js";
|
||||
|
||||
export default function StaffingPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Staffing Suggestions</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Find the best resource match for your project needs</p>
|
||||
</div>
|
||||
<StaffingPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export default function TimelineLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-0 animate-pulse">
|
||||
{/* 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="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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Resource rows */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex border-b border-gray-100 dark:border-gray-800 py-3">
|
||||
{/* 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>
|
||||
{/* 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%" }} />
|
||||
)}
|
||||
{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%" }} />
|
||||
</>
|
||||
)}
|
||||
{i % 3 === 2 && (
|
||||
<div className="h-7 rounded-lg bg-green-100 dark:bg-green-900/30" style={{ width: "45%", marginLeft: "20%" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TimelineView } from "~/components/timeline/TimelineView.js";
|
||||
|
||||
export default function TimelinePage() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-6 pb-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Timeline</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Interactive resource planning timeline</p>
|
||||
</div>
|
||||
<TimelineView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
||||
|
||||
export const metadata = { title: "My Vacations — Planarchy" };
|
||||
|
||||
export default function MyVacationsPage() {
|
||||
return <MyVacationsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VacationClient } from "~/components/vacations/VacationClient.js";
|
||||
|
||||
export default function VacationsPage() {
|
||||
return <VacationClient />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "~/server/auth.js";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { NextResponse } from "next/server";
|
||||
import * as XLSX from "xlsx";
|
||||
import { buildSplitAllocationReadModel } from "@planarchy/application";
|
||||
import { prisma } from "@planarchy/db";
|
||||
import type { AllocationLike } from "@planarchy/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
import { AllocationReport } from "~/components/reports/AllocationReport.js";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const startDate = searchParams.get("startDate") ? new Date(searchParams.get("startDate")!) : new Date();
|
||||
const endDate = searchParams.get("endDate") ? new Date(searchParams.get("endDate")!) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
const format = searchParams.get("format") ?? "pdf";
|
||||
|
||||
const [demandRequirements, assignments] = await Promise.all([
|
||||
prisma.demandRequirement.findMany({
|
||||
where: {
|
||||
status: { not: "CANCELLED" },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
include: {
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
},
|
||||
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
|
||||
take: 1000,
|
||||
}),
|
||||
prisma.assignment.findMany({
|
||||
where: {
|
||||
status: { not: "CANCELLED" },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
include: {
|
||||
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
|
||||
project: { select: { id: true, name: true, shortCode: true } },
|
||||
},
|
||||
orderBy: [{ project: { name: "asc" } }, { startDate: "asc" }],
|
||||
take: 1000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const allocationView = buildSplitAllocationReadModel({
|
||||
demandRequirements,
|
||||
assignments,
|
||||
});
|
||||
const assignmentRows = allocationView.assignments.slice(0, 500);
|
||||
|
||||
const rows = assignmentRows.map((a: AllocationLike & {
|
||||
resource?: { displayName?: string | null } | null;
|
||||
project?: { shortCode: string; name: string } | null;
|
||||
}) => ({
|
||||
resourceName: a.resource?.displayName ?? "Unknown",
|
||||
projectName: a.project ? `${a.project.shortCode} — ${a.project.name}` : "Unknown project",
|
||||
role: a.role ?? "",
|
||||
startDate: new Date(a.startDate).toLocaleDateString("en-GB"),
|
||||
endDate: new Date(a.endDate).toLocaleDateString("en-GB"),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
dailyCostCents: a.dailyCostCents,
|
||||
}));
|
||||
|
||||
const ts = Date.now();
|
||||
|
||||
if (format === "xlsx") {
|
||||
const sheetData = rows.map((r: typeof rows[number]) => ({
|
||||
Resource: r.resourceName,
|
||||
Project: r.projectName,
|
||||
Role: r.role,
|
||||
"Start Date": r.startDate,
|
||||
"End Date": r.endDate,
|
||||
"Hours/Day": r.hoursPerDay,
|
||||
"Daily Cost (ct)": r.dailyCostCents,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(sheetData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Allocations");
|
||||
const buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
return new NextResponse(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="allocations-${ts}.xlsx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const title = `Allocation Report ${startDate.toLocaleDateString("en-GB")} – ${endDate.toLocaleDateString("en-GB")}`;
|
||||
const generatedAt = new Date().toLocaleString("en-GB");
|
||||
const doc = createElement(AllocationReport, { title, generatedAt, rows });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const buffer = await renderToBuffer(doc as any);
|
||||
|
||||
return new NextResponse(buffer as unknown as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="allocations-${ts}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { eventBus } from "@planarchy/api/sse";
|
||||
import { SSE_EVENT_TYPES } from "@planarchy/shared";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial connection confirmation
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
||||
);
|
||||
|
||||
// Subscribe to event bus
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
// Client disconnected
|
||||
}
|
||||
});
|
||||
|
||||
// Heartbeat every 30 seconds
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: SSE_EVENT_TYPES.PING, timestamp: new Date().toISOString() })}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Cleanup on close
|
||||
return () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createTRPCContext } 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";
|
||||
|
||||
const handler = async (req: NextRequest) => {
|
||||
const session = await auth();
|
||||
|
||||
const dbUser = session?.user?.email
|
||||
? await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
select: { id: true, systemRole: true, permissionOverrides: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options: any = {
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createTRPCContext({ session, dbUser }),
|
||||
};
|
||||
|
||||
if (process.env["NODE_ENV"] === "development") {
|
||||
options.onError = ({ path, error }: { path?: string; error: { message: string } }) => {
|
||||
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
|
||||
};
|
||||
}
|
||||
|
||||
return fetchRequestHandler(options);
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-50 to-brand-100">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Planarchy</h1>
|
||||
<p className="text-gray-500 mt-2">Resource Planning & Staffing</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
placeholder="admin@planarchy.dev"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-xs text-gray-500 font-medium mb-2">Demo accounts:</p>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<p><span className="font-mono">admin@planarchy.dev</span> / admin123 (Admin)</p>
|
||||
<p><span className="font-mono">manager@planarchy.dev</span> / manager123 (Manager)</p>
|
||||
<p><span className="font-mono">viewer@planarchy.dev</span> / viewer123 (Viewer)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ─── Accent Color CSS Variables ────────────────────────────────────────────
|
||||
Each data-accent value sets the --accent-* RGB triplets consumed by Tailwind
|
||||
brand-* classes. The format is "R G B" (no commas) for Tailwind opacity support.
|
||||
*/
|
||||
|
||||
/* Sky Blue (default) */
|
||||
:root,
|
||||
[data-accent="sky"] {
|
||||
--accent-50: 240 249 255;
|
||||
--accent-100: 224 242 254;
|
||||
--accent-200: 186 230 253;
|
||||
--accent-300: 125 211 252;
|
||||
--accent-400: 56 189 248;
|
||||
--accent-500: 14 165 233;
|
||||
--accent-600: 2 132 199;
|
||||
--accent-700: 3 105 161;
|
||||
--accent-800: 7 89 133;
|
||||
--accent-900: 12 74 110;
|
||||
}
|
||||
|
||||
[data-accent="indigo"] {
|
||||
--accent-50: 238 242 255;
|
||||
--accent-100: 224 231 255;
|
||||
--accent-200: 199 210 254;
|
||||
--accent-300: 165 180 252;
|
||||
--accent-400: 129 140 248;
|
||||
--accent-500: 99 102 241;
|
||||
--accent-600: 79 70 229;
|
||||
--accent-700: 67 56 202;
|
||||
--accent-800: 55 48 163;
|
||||
--accent-900: 49 46 129;
|
||||
}
|
||||
|
||||
[data-accent="violet"] {
|
||||
--accent-50: 245 243 255;
|
||||
--accent-100: 237 233 254;
|
||||
--accent-200: 221 214 254;
|
||||
--accent-300: 196 181 253;
|
||||
--accent-400: 167 139 250;
|
||||
--accent-500: 139 92 246;
|
||||
--accent-600: 124 58 237;
|
||||
--accent-700: 109 40 217;
|
||||
--accent-800: 91 33 182;
|
||||
--accent-900: 76 29 149;
|
||||
}
|
||||
|
||||
[data-accent="emerald"] {
|
||||
--accent-50: 236 253 245;
|
||||
--accent-100: 209 250 229;
|
||||
--accent-200: 167 243 208;
|
||||
--accent-300: 110 231 183;
|
||||
--accent-400: 52 211 153;
|
||||
--accent-500: 16 185 129;
|
||||
--accent-600: 5 150 105;
|
||||
--accent-700: 4 120 87;
|
||||
--accent-800: 6 95 70;
|
||||
--accent-900: 6 78 59;
|
||||
}
|
||||
|
||||
[data-accent="rose"] {
|
||||
--accent-50: 255 241 242;
|
||||
--accent-100: 255 228 230;
|
||||
--accent-200: 254 205 211;
|
||||
--accent-300: 253 164 175;
|
||||
--accent-400: 251 113 133;
|
||||
--accent-500: 244 63 94;
|
||||
--accent-600: 225 29 72;
|
||||
--accent-700: 190 18 60;
|
||||
--accent-800: 159 18 57;
|
||||
--accent-900: 136 19 55;
|
||||
}
|
||||
|
||||
[data-accent="amber"] {
|
||||
--accent-50: 255 251 235;
|
||||
--accent-100: 254 243 199;
|
||||
--accent-200: 253 230 138;
|
||||
--accent-300: 252 211 77;
|
||||
--accent-400: 251 191 36;
|
||||
--accent-500: 245 158 11;
|
||||
--accent-600: 217 119 6;
|
||||
--accent-700: 180 83 9;
|
||||
--accent-800: 146 64 14;
|
||||
--accent-900: 120 53 15;
|
||||
}
|
||||
|
||||
/* ─── Light Theme Surface Variables ─────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--surface-page: 249 250 251; /* gray-50 */
|
||||
--surface-card: 255 255 255; /* white */
|
||||
--surface-elevated: 249 250 251; /* gray-50 */
|
||||
--surface-input: 255 255 255; /* white */
|
||||
--border-subtle: 229 231 235; /* gray-200 */
|
||||
--border-input: 209 213 219; /* gray-300 */
|
||||
--text-primary: 17 24 39; /* gray-900 */
|
||||
--text-secondary: 75 85 99; /* gray-600 */
|
||||
--text-muted: 107 114 128; /* gray-500 */
|
||||
--text-very-muted: 156 163 175; /* gray-400 */
|
||||
}
|
||||
|
||||
/* ─── Dark Theme Surface Variables ──────────────────────────────────────── */
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--surface-page: 10 11 16; /* near-black page bg */
|
||||
--surface-card: 24 27 38; /* dark card bg */
|
||||
--surface-elevated: 32 36 50; /* slightly lighter - table headers etc */
|
||||
--surface-input: 32 36 50; /* input bg */
|
||||
--border-subtle: 45 51 71; /* dark border */
|
||||
--border-input: 58 65 90; /* slightly lighter border */
|
||||
--text-primary: 232 234 240; /* near-white */
|
||||
--text-secondary: 163 173 197; /* medium gray */
|
||||
--text-muted: 107 117 142; /* muted */
|
||||
--text-very-muted: 75 84 107; /* very muted */
|
||||
}
|
||||
|
||||
/* ─── Base Layer: Apply variables to body ────────────────────────────────── */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: rgb(var(--surface-page));
|
||||
color: rgb(var(--text-primary));
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Smooth transition for theme changes */
|
||||
*, *::before, *::after {
|
||||
transition-property: background-color, border-color, color;
|
||||
transition-duration: 0.1s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: rgb(var(--surface-card));
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--border-subtle));
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Dark Mode Overrides for Common Tailwind Patterns ──────────────────── */
|
||||
/* These override the most commonly used utility classes in the app */
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: rgb(var(--surface-card)) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: rgb(var(--surface-elevated)) !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: rgb(45 51 71) !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-100 {
|
||||
border-color: rgb(var(--border-subtle)) !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-200 {
|
||||
border-color: rgb(var(--border-subtle)) !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: rgb(var(--border-input)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: rgb(var(--text-primary)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
color: rgb(var(--text-primary)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: rgb(var(--text-secondary)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: rgb(var(--text-secondary)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: rgb(var(--text-muted)) !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-400 {
|
||||
color: rgb(var(--text-very-muted)) !important;
|
||||
}
|
||||
|
||||
.dark input,
|
||||
.dark select,
|
||||
.dark textarea {
|
||||
background-color: rgb(var(--surface-input));
|
||||
border-color: rgb(var(--border-input));
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
.dark input::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
/* Table alternating / hover */
|
||||
.dark .hover\:bg-gray-50:hover {
|
||||
background-color: rgb(var(--surface-elevated)) !important;
|
||||
}
|
||||
|
||||
.dark .divide-gray-100 > * + * {
|
||||
border-color: rgb(var(--border-subtle)) !important;
|
||||
}
|
||||
|
||||
/* Status badge adjustments in dark mode - keep them readable */
|
||||
.dark .bg-green-100 { background-color: rgb(6 78 59 / 0.4) !important; }
|
||||
.dark .text-green-700 { color: rgb(52 211 153) !important; }
|
||||
.dark .bg-yellow-100 { background-color: rgb(120 53 15 / 0.4) !important; }
|
||||
.dark .text-yellow-700 { color: rgb(251 191 36) !important; }
|
||||
.dark .bg-blue-100 { background-color: rgb(30 58 138 / 0.4) !important; }
|
||||
.dark .text-blue-700 { color: rgb(96 165 250) !important; }
|
||||
.dark .bg-red-100 { background-color: rgb(127 29 29 / 0.4) !important; }
|
||||
.dark .text-red-600 { color: rgb(248 113 113) !important; }
|
||||
.dark .text-red-700 { color: rgb(248 113 113) !important; }
|
||||
.dark .bg-gray-100 { background-color: rgb(var(--surface-elevated)) !important; }
|
||||
.dark .text-gray-700 { color: rgb(var(--text-secondary)) !important; }
|
||||
.dark .bg-purple-100 { background-color: rgb(76 29 149 / 0.4) !important; }
|
||||
.dark .text-purple-700 { color: rgb(196 181 253) !important; }
|
||||
.dark .bg-amber-50 { background-color: rgb(120 53 15 / 0.2) !important; }
|
||||
|
||||
/* Modal / overlay */
|
||||
.dark .shadow-2xl {
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.6) !important;
|
||||
}
|
||||
|
||||
/* ─── Timeline utilities (unchanged) ────────────────────────────────────── */
|
||||
|
||||
@layer utilities {
|
||||
.timeline-row {
|
||||
@apply flex border-b border-gray-100 hover:bg-gray-50/50;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.allocation-block:hover {
|
||||
@apply ring-2 ring-white ring-offset-1;
|
||||
}
|
||||
|
||||
.allocation-block.dragging {
|
||||
@apply opacity-75 shadow-lg scale-105;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TRPCProvider } from "~/lib/trpc/provider.js";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Planarchy — Resource Planning",
|
||||
description: "Interactive resource planning and project staffing tool",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{__html: `
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('planarchy_theme') || '{}');
|
||||
if (p.mode === 'dark') document.documentElement.classList.add('dark');
|
||||
if (p.accent) document.documentElement.setAttribute('data-accent', p.accent);
|
||||
} catch(e) {}
|
||||
`}} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-gray-50 font-sans antialiased">
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
redirect("/resources");
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { parseSkillMatrixWorkbook, matchRoleName } from "~/lib/skillMatrixParser.js";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
|
||||
interface ParsedEntry {
|
||||
fileName: string;
|
||||
candidateEid: string; // guessed from filename (no extension, lowercased)
|
||||
selectedEid: string;
|
||||
skills: SkillEntry[];
|
||||
employeeInfo: Record<string, string>;
|
||||
matchedRoleName: string | null;
|
||||
status: "pending" | "matched" | "unmatched";
|
||||
}
|
||||
|
||||
export function BatchSkillImport() {
|
||||
const [entries, setEntries] = useState<ParsedEntry[]>([]);
|
||||
const [result, setResult] = useState<{ updated: number; notFound: number } | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: roles } = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const batchMutation = trpc.resource.batchImportSkillMatrices.useMutation({
|
||||
onSuccess: (data) => { setResult(data); setSubmitting(false); },
|
||||
onError: (err) => { setError(err.message); setSubmitting(false); },
|
||||
});
|
||||
|
||||
async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
|
||||
const roleNames = (roles ?? []).map((r) => r.name);
|
||||
const resourceList = (resources?.resources ?? []) as SimpleResource[];
|
||||
|
||||
const parsed: ParsedEntry[] = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "");
|
||||
// Guess EID: try matching against resource displayName or eid
|
||||
const candidateEid = baseName.toLowerCase().replace(/\s+/g, ".");
|
||||
const matchedResource = resourceList.find(
|
||||
(r) =>
|
||||
r.eid.toLowerCase() === candidateEid ||
|
||||
r.displayName.toLowerCase().replace(/\s+/g, ".") === candidateEid ||
|
||||
r.displayName.toLowerCase() === baseName.toLowerCase(),
|
||||
);
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = parseSkillMatrixWorkbook(buffer);
|
||||
|
||||
let roleId: string | undefined;
|
||||
let matchedRoleName: string | undefined;
|
||||
if (result.employeeInfo.areaOfExpertise) {
|
||||
const matched = matchRoleName(result.employeeInfo.areaOfExpertise, roleNames);
|
||||
if (matched) {
|
||||
const role = (roles ?? []).find((r) => r.name === matched);
|
||||
roleId = role?.id;
|
||||
matchedRoleName = matched;
|
||||
}
|
||||
}
|
||||
|
||||
const empInfo: Record<string, string> = {};
|
||||
if (roleId) empInfo["roleId"] = roleId;
|
||||
if (result.employeeInfo.portfolioUrl) empInfo["portfolioUrl"] = result.employeeInfo.portfolioUrl;
|
||||
|
||||
return {
|
||||
fileName: file.name,
|
||||
candidateEid,
|
||||
selectedEid: matchedResource?.eid ?? candidateEid,
|
||||
skills: result.skills,
|
||||
employeeInfo: empInfo,
|
||||
matchedRoleName: matchedRoleName ?? null,
|
||||
status: matchedResource ? "matched" : "unmatched",
|
||||
} satisfies ParsedEntry;
|
||||
} catch {
|
||||
return {
|
||||
fileName: file.name,
|
||||
candidateEid,
|
||||
selectedEid: matchedResource?.eid ?? "",
|
||||
skills: [],
|
||||
employeeInfo: {},
|
||||
matchedRoleName: null,
|
||||
status: "unmatched",
|
||||
} satisfies ParsedEntry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setEntries(parsed);
|
||||
}
|
||||
|
||||
function updateEid(idx: number, eid: string) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, i) =>
|
||||
i === idx
|
||||
? {
|
||||
...e,
|
||||
selectedEid: eid,
|
||||
status: eid ? "matched" : "unmatched",
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const toImport = entries.filter((e) => e.selectedEid && e.skills.length > 0);
|
||||
if (toImport.length === 0) return;
|
||||
setSubmitting(true);
|
||||
batchMutation.mutate({
|
||||
entries: toImport.map((e) => ({
|
||||
eid: e.selectedEid,
|
||||
skills: e.skills,
|
||||
employeeInfo: {
|
||||
...(e.employeeInfo["roleId"] ? { roleId: e.employeeInfo["roleId"] } : {}),
|
||||
...(e.employeeInfo["portfolioUrl"] ? { portfolioUrl: e.employeeInfo["portfolioUrl"] } : {}),
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
type SimpleResource = { eid: string; displayName: string };
|
||||
const resourceList = (resources?.resources ?? []) as SimpleResource[];
|
||||
const matched = entries.filter((e) => e.status === "matched").length;
|
||||
const unmatched = entries.filter((e) => e.status === "unmatched").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Batch Skill Matrix Import</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Upload multiple skill matrix files at once. Files are matched to resources by filename.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer hover:border-brand-400 transition-colors mb-6 bg-white dark:bg-gray-800"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<svg className="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Click to select multiple .xlsx files</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Name files after resource EID or display name for automatic matching</p>
|
||||
<input ref={fileRef} type="file" accept=".xlsx,.xls" multiple className="hidden" onChange={handleFiles} />
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{entries.length > 0 && (
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg px-4 py-2 text-sm">
|
||||
<span className="font-semibold text-green-700 dark:text-green-400">{matched}</span>
|
||||
<span className="text-green-600 dark:text-green-400 ml-1">matched</span>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg px-4 py-2 text-sm">
|
||||
<span className="font-semibold text-yellow-700 dark:text-yellow-400">{unmatched}</span>
|
||||
<span className="text-yellow-600 dark:text-yellow-400 ml-1">unmatched (select EID manually)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entries table */}
|
||||
{entries.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">File</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Resource EID</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Skills</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role Match</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((entry, idx) => (
|
||||
<tr key={idx} className={entry.status === "unmatched" ? "bg-yellow-50 dark:bg-yellow-900/10" : ""}>
|
||||
<td className="px-4 py-3 text-xs text-gray-600 dark:text-gray-400 font-mono">{entry.fileName}</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.status === "matched" ? (
|
||||
<span className="font-mono text-sm text-gray-800 dark:text-gray-100">{entry.selectedEid}</span>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-2 py-1.5 border border-yellow-300 dark:border-yellow-600 rounded text-sm bg-white dark:bg-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
value={entry.selectedEid}
|
||||
onChange={(e) => updateEid(idx, e.target.value)}
|
||||
>
|
||||
<option value="">— Select resource —</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.eid} value={r.eid}>{r.displayName} ({r.eid})</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-700 dark:text-gray-300">{entry.skills.length}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{entry.matchedRoleName ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
entry.status === "matched" ? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400"
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
|
||||
Import complete: <strong>{result.updated}</strong> updated, <strong>{result.notFound}</strong> not found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && !result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={submitting || matched === 0}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Importing…" : `Import ${entries.filter((e) => e.selectedEid && e.skills.length > 0).length} Files`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type ClientRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type ClientNode = ClientRow & { children: ClientNode[] };
|
||||
|
||||
type EditingClient = {
|
||||
id?: string;
|
||||
name: string;
|
||||
code: string;
|
||||
parentId: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
function ClientTreeNode({
|
||||
node,
|
||||
onEdit,
|
||||
onAddChild,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: ClientNode;
|
||||
onEdit: (c: ClientRow) => void;
|
||||
onAddChild: (parentId: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(depth < 1);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
{expanded ? "▼" : "▶"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
|
||||
{node.name}
|
||||
{node.code && <span className="text-gray-400 font-mono ml-1 text-xs">[{node.code}]</span>}
|
||||
</span>
|
||||
{!node.isActive && <span className="text-xs text-gray-400 italic">inactive</span>}
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddChild(node.id)}
|
||||
className="text-xs text-green-600 hover:text-green-800 font-medium"
|
||||
>
|
||||
+ Child
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(node)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && node.children.map((child) => (
|
||||
<ClientTreeNode key={child.id} node={child} onEdit={onEdit} onAddChild={onAddChild} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientsAdminClient() {
|
||||
const [editing, setEditing] = useState<EditingClient | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: tree, isLoading } = trpc.clientEntity.getTree.useQuery();
|
||||
const { data: flatList } = trpc.clientEntity.list.useQuery();
|
||||
|
||||
const createMut = trpc.clientEntity.create.useMutation({
|
||||
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateMut = trpc.clientEntity.update.useMutation({
|
||||
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
const allClients = (flatList ?? []) as unknown as ClientRow[];
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
setEditing({ name: "", code: "", parentId: parentId ?? "", sortOrder: 0 });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEdit(c: ClientRow) {
|
||||
setEditing({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
code: c.code ?? "",
|
||||
parentId: c.parentId ?? "",
|
||||
sortOrder: c.sortOrder,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
if (editing.id) {
|
||||
updateMut.mutate({
|
||||
id: editing.id,
|
||||
data: {
|
||||
name: editing.name,
|
||||
code: editing.code || undefined,
|
||||
parentId: editing.parentId || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
name: editing.name,
|
||||
code: editing.code || undefined,
|
||||
parentId: editing.parentId || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMut.isPending || updateMut.isPending;
|
||||
const treeNodes = (tree ?? []) as unknown as ClientNode[];
|
||||
|
||||
// Simple client-side filter on tree
|
||||
function filterTree(nodes: ClientNode[], q: string): ClientNode[] {
|
||||
if (!q) return nodes;
|
||||
const lower = q.toLowerCase();
|
||||
return nodes.reduce<ClientNode[]>((acc, node) => {
|
||||
const filteredChildren = filterTree(node.children, q);
|
||||
if (node.name.toLowerCase().includes(lower) || (node.code ?? "").toLowerCase().includes(lower) || filteredChildren.length > 0) {
|
||||
acc.push({ ...node, children: filteredChildren });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
const filteredTree = filterTree(treeNodes, search);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreate()}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search clients..."
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
|
||||
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
|
||||
{!isLoading && filteredTree.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
{search ? "No clients match your search." : "No clients yet."}
|
||||
</div>
|
||||
)}
|
||||
{filteredTree.map((node) => (
|
||||
<ClientTreeNode key={node.id} node={node} onEdit={openEdit} onAddChild={(pid) => openCreate(pid)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{editing && (
|
||||
<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-md mx-4">
|
||||
<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">
|
||||
{editing.id ? "Edit Client" : "Add Client"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
placeholder="e.g. BMW Group"
|
||||
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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
|
||||
placeholder="BMW"
|
||||
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 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client</label>
|
||||
<select
|
||||
value={editing.parentId}
|
||||
onChange={(e) => setEditing({ ...editing, parentId: 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"
|
||||
>
|
||||
<option value="">— Top level (no parent) —</option>
|
||||
{allClients
|
||||
.filter((c) => c.id !== editing.id && c.isActive)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} {c.code ? `[${c.code}]` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditing(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={handleSave}
|
||||
disabled={isPending || !editing.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type CountryRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dailyWorkingHours: number;
|
||||
scheduleRules: unknown;
|
||||
isActive: boolean;
|
||||
metroCities: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type EditingCountry = {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dailyWorkingHours: number;
|
||||
hasSpainRules: boolean;
|
||||
fridayHours: number;
|
||||
summerFrom: string;
|
||||
summerTo: string;
|
||||
summerHours: number;
|
||||
regularHours: number;
|
||||
};
|
||||
|
||||
const emptyCountry: EditingCountry = {
|
||||
code: "",
|
||||
name: "",
|
||||
dailyWorkingHours: 8,
|
||||
hasSpainRules: false,
|
||||
fridayHours: 6.5,
|
||||
summerFrom: "07-01",
|
||||
summerTo: "09-15",
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
};
|
||||
|
||||
function parseSpainRules(rules: unknown): Partial<EditingCountry> {
|
||||
if (!rules || typeof rules !== "object") return { hasSpainRules: false };
|
||||
const r = rules as Record<string, unknown>;
|
||||
if (r.type !== "spain") return { hasSpainRules: false };
|
||||
const sp = r as { fridayHours?: number; summerPeriod?: { from?: string; to?: string }; summerHours?: number; regularHours?: number };
|
||||
return {
|
||||
hasSpainRules: true,
|
||||
fridayHours: sp.fridayHours ?? 6.5,
|
||||
summerFrom: sp.summerPeriod?.from ?? "07-01",
|
||||
summerTo: sp.summerPeriod?.to ?? "09-15",
|
||||
summerHours: sp.summerHours ?? 6.5,
|
||||
regularHours: sp.regularHours ?? 9,
|
||||
};
|
||||
}
|
||||
|
||||
export function CountriesClient() {
|
||||
const [editing, setEditing] = useState<EditingCountry | null>(null);
|
||||
const [cityName, setCityName] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: countries, isLoading } = trpc.country.list.useQuery();
|
||||
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable JSONB scheduleRules schema
|
||||
const createMut = trpc.country.create.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateMut = trpc.country.update.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const createCityMut = trpc.country.createCity.useMutation({
|
||||
onSuccess: () => { void utils.country.list.invalidate(); setCityName(""); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const deleteCityMut = trpc.country.deleteCity.useMutation({
|
||||
onSuccess: () => void utils.country.list.invalidate(),
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setEditing({ ...emptyCountry });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEdit(c: CountryRow) {
|
||||
const spainParts = parseSpainRules(c.scheduleRules);
|
||||
setEditing({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
dailyWorkingHours: c.dailyWorkingHours,
|
||||
hasSpainRules: false,
|
||||
fridayHours: 6.5,
|
||||
summerFrom: "07-01",
|
||||
summerTo: "09-15",
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
...spainParts,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
const scheduleRules = editing.hasSpainRules
|
||||
? {
|
||||
type: "spain" as const,
|
||||
fridayHours: editing.fridayHours,
|
||||
summerPeriod: { from: editing.summerFrom, to: editing.summerTo },
|
||||
summerHours: editing.summerHours,
|
||||
regularHours: editing.regularHours,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (editing.id) {
|
||||
updateMut.mutate({
|
||||
id: editing.id,
|
||||
data: {
|
||||
code: editing.code,
|
||||
name: editing.name,
|
||||
dailyWorkingHours: editing.dailyWorkingHours,
|
||||
scheduleRules,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
code: editing.code,
|
||||
name: editing.name,
|
||||
dailyWorkingHours: editing.dailyWorkingHours,
|
||||
scheduleRules,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCity(countryId: string) {
|
||||
if (!cityName.trim()) return;
|
||||
createCityMut.mutate({ countryId, name: cityName.trim() });
|
||||
}
|
||||
|
||||
const isPending = createMut.isPending || updateMut.isPending;
|
||||
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Countries</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage countries, daily working hours, and metro cities
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Country
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Country List */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<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-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
|
||||
)}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No countries yet.</td></tr>
|
||||
)}
|
||||
{rows.map((c) => (
|
||||
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{c.dailyWorkingHours}h</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{c.scheduleRules && typeof c.scheduleRules === "object" && (c.scheduleRules as Record<string, unknown>).type === "spain" ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">Spain</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Standard</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
{c.metroCities.length} cities {expandedId === c.id ? "▲" : "▼"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Expanded Metro Cities */}
|
||||
{expandedId && (() => {
|
||||
const country = rows.find((c) => c.id === expandedId);
|
||||
if (!country) return null;
|
||||
return (
|
||||
<div className="mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Metro Cities for {country.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{country.metroCities.map((city) => (
|
||||
<span key={city.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
|
||||
{city.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete metro city "${city.name}"?`)) {
|
||||
deleteCityMut.mutate({ id: city.id });
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 text-xs leading-none ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{country.metroCities.length === 0 && (
|
||||
<span className="text-sm text-gray-400">No metro cities yet</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={cityName}
|
||||
onChange={(e) => setCityName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAddCity(country.id); }}
|
||||
placeholder="New city name..."
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddCity(country.id)}
|
||||
disabled={createCityMut.isPending || !cityName.trim()}
|
||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{editing && (
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editing.id ? "Edit Country" : "Add Country"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditing(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-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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
onChange={(e) => setEditing({ ...editing, code: e.target.value.toUpperCase() })}
|
||||
maxLength={3}
|
||||
placeholder="DE"
|
||||
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 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Daily Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.dailyWorkingHours}
|
||||
onChange={(e) => setEditing({ ...editing, dailyWorkingHours: parseFloat(e.target.value) || 8 })}
|
||||
min={1}
|
||||
max={24}
|
||||
step={0.5}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
placeholder="Germany"
|
||||
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>
|
||||
|
||||
{/* Spain Schedule Rules */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.hasSpainRules}
|
||||
onChange={(e) => setEditing({ ...editing, hasSpainRules: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Variable schedule (Spain-type)
|
||||
</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>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.fridayHours}
|
||||
onChange={(e) => setEditing({ ...editing, fridayHours: parseFloat(e.target.value) || 6.5 })}
|
||||
step={0.5}
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Regular Hours (Mon-Thu)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.regularHours}
|
||||
onChange={(e) => setEditing({ ...editing, regularHours: parseFloat(e.target.value) || 9 })}
|
||||
step={0.5}
|
||||
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>
|
||||
</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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.summerFrom}
|
||||
onChange={(e) => setEditing({ ...editing, summerFrom: e.target.value })}
|
||||
placeholder="07-01"
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer To</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.summerTo}
|
||||
onChange={(e) => setEditing({ ...editing, summerTo: e.target.value })}
|
||||
placeholder="09-15"
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Summer Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.summerHours}
|
||||
onChange={(e) => setEditing({ ...editing, summerHours: parseFloat(e.target.value) || 6.5 })}
|
||||
step={0.5}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditing(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={handleSave}
|
||||
disabled={isPending || !editing.code || !editing.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type EffortUnitMode = "per_frame" | "per_item" | "flat";
|
||||
|
||||
type EditingRule = {
|
||||
id?: string;
|
||||
scopeType: string;
|
||||
discipline: string;
|
||||
chapter: string;
|
||||
unitMode: EffortUnitMode;
|
||||
hoursPerUnit: number;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type EditingRuleSet = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isDefault: boolean;
|
||||
rules: EditingRule[];
|
||||
};
|
||||
|
||||
const UNIT_MODE_LABELS: Record<EffortUnitMode, string> = {
|
||||
per_frame: "Per frame",
|
||||
per_item: "Per item",
|
||||
flat: "Flat",
|
||||
};
|
||||
|
||||
const SCOPE_TYPE_PRESETS = ["SHOT", "ASSET", "ENVIRONMENT", "SEQUENCE", "OTHER"];
|
||||
const DISCIPLINE_PRESETS = [
|
||||
"3D Animation",
|
||||
"3D Lighting",
|
||||
"3D Modeling",
|
||||
"3D Rigging",
|
||||
"3D Environment",
|
||||
"Compositing",
|
||||
"Motion Graphics",
|
||||
"Art Direction",
|
||||
"Conception / R&D",
|
||||
"Project Management",
|
||||
"Production Supervisor",
|
||||
"DataPrep",
|
||||
"Audio Production",
|
||||
];
|
||||
|
||||
const emptyRule: EditingRule = {
|
||||
scopeType: "SHOT",
|
||||
discipline: "",
|
||||
chapter: "",
|
||||
unitMode: "per_frame",
|
||||
hoursPerUnit: 0,
|
||||
description: "",
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
const emptyRuleSet: EditingRuleSet = {
|
||||
name: "",
|
||||
description: "",
|
||||
isDefault: false,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
export function EffortRulesClient() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
|
||||
|
||||
const createMutation = trpc.effortRule.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.effortRule.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
});
|
||||
const updateMutation = trpc.effortRule.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.effortRule.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
});
|
||||
const deleteMutation = trpc.effortRule.delete.useMutation({
|
||||
onSuccess: () => utils.effortRule.list.invalidate(),
|
||||
});
|
||||
|
||||
const [editing, setEditing] = useState<EditingRuleSet | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name,
|
||||
description: editing.description || undefined,
|
||||
isDefault: editing.isDefault,
|
||||
rules: editing.rules.map((r, i) => ({
|
||||
scopeType: r.scopeType,
|
||||
discipline: r.discipline,
|
||||
...(r.chapter ? { chapter: r.chapter } : {}),
|
||||
unitMode: r.unitMode,
|
||||
hoursPerUnit: r.hoursPerUnit,
|
||||
...(r.description ? { description: r.description } : {}),
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
|
||||
if (editing.id) {
|
||||
updateMutation.mutate({ id: editing.id, ...payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(ruleSet: NonNullable<typeof ruleSets>[number]) {
|
||||
setEditing({
|
||||
id: ruleSet.id,
|
||||
name: ruleSet.name,
|
||||
description: ruleSet.description ?? "",
|
||||
isDefault: ruleSet.isDefault,
|
||||
rules: ruleSet.rules.map((r) => ({
|
||||
id: r.id,
|
||||
scopeType: r.scopeType,
|
||||
discipline: r.discipline,
|
||||
chapter: r.chapter ?? "",
|
||||
unitMode: r.unitMode as EffortUnitMode,
|
||||
hoursPerUnit: r.hoursPerUnit,
|
||||
description: r.description ?? "",
|
||||
sortOrder: r.sortOrder,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: editing.rules.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
function updateRule(index: number, updates: Partial<EditingRule>) {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
|
||||
});
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Effort Rules</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Define rules for auto-generating demand lines from scope items.
|
||||
</p>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing({ ...emptyRuleSet })}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
New rule set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{editing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{editing.id ? "Edit rule set" : "New rule set"}
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="e.g. CGI Standard Rules"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
|
||||
<input
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.isDefault}
|
||||
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Default rule set (auto-selected for new estimates)
|
||||
</label>
|
||||
|
||||
{/* Rules table */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
|
||||
<button
|
||||
onClick={addRule}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing.rules.length === 0 ? (
|
||||
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
|
||||
No rules yet. Add rules to define how scope items expand into demand lines.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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="pl-2 py-2 font-medium w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editing.rules.map((rule, i) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<select
|
||||
value={rule.scopeType}
|
||||
onChange={(e) => updateRule(i, { scopeType: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
>
|
||||
{SCOPE_TYPE_PRESETS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={rule.discipline}
|
||||
onChange={(e) => updateRule(i, { discipline: e.target.value })}
|
||||
list="discipline-presets"
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
placeholder="Discipline"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={rule.chapter}
|
||||
onChange={(e) => updateRule(i, { chapter: e.target.value })}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
placeholder="Chapter"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
value={rule.unitMode}
|
||||
onChange={(e) => updateRule(i, { unitMode: e.target.value as EffortUnitMode })}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
>
|
||||
{(Object.entries(UNIT_MODE_LABELS) as [EffortUnitMode, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rule.hoursPerUnit}
|
||||
onChange={(e) => updateRule(i, { hoursPerUnit: parseFloat(e.target.value) || 0 })}
|
||||
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="pl-2 py-2">
|
||||
<button
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Remove"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<datalist id="discipline-presets">
|
||||
{DISCIPLINE_PRESETS.map((d) => (
|
||||
<option key={d} value={d} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !editing.name.trim()}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(createMutation.error || updateMutation.error) && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{createMutation.error?.message || updateMutation.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
|
||||
|
||||
{ruleSets && ruleSets.length === 0 && !editing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
||||
No effort rule sets yet. Create one to define how scope items expand into demand lines.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ruleSets?.map((rs) => (
|
||||
<div key={rs.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{rs.name}</h3>
|
||||
{rs.isDefault && (
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">{rs.rules.length} rules</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === rs.id ? null : rs.id)}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{expandedId === rs.id ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(rs)}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete rule set "${rs.name}"?`)) {
|
||||
deleteMutation.mutate({ id: rs.id });
|
||||
}
|
||||
}}
|
||||
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{rs.description && <p className="mt-1 text-sm text-gray-500">{rs.description}</p>}
|
||||
|
||||
{expandedId === rs.id && rs.rules.length > 0 && (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rs.rules.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 text-gray-900">{r.scopeType}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{r.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{r.chapter || "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{UNIT_MODE_LABELS[r.unitMode as EffortUnitMode] ?? r.unitMode}</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-700">{r.hoursPerUnit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type EditingRule = {
|
||||
id?: string;
|
||||
chapter: string;
|
||||
location: string;
|
||||
level: string;
|
||||
costMultiplier: number;
|
||||
billMultiplier: number;
|
||||
shoringRatio: string;
|
||||
additionalEffortRatio: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type EditingSet = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isDefault: boolean;
|
||||
rules: EditingRule[];
|
||||
};
|
||||
|
||||
const CHAPTER_PRESETS = [
|
||||
"Animation",
|
||||
"Compositing",
|
||||
"3D Modeling",
|
||||
"3D Lighting",
|
||||
"3D Rigging",
|
||||
"3D Environment",
|
||||
"Motion Graphics",
|
||||
"Art Direction",
|
||||
"Project Management",
|
||||
];
|
||||
|
||||
const LOCATION_PRESETS = [
|
||||
"Germany",
|
||||
"India",
|
||||
"Poland",
|
||||
"Romania",
|
||||
"Spain",
|
||||
"UK",
|
||||
"USA",
|
||||
"Canada",
|
||||
];
|
||||
|
||||
const LEVEL_PRESETS = [
|
||||
"Junior",
|
||||
"Mid",
|
||||
"Senior",
|
||||
"Lead",
|
||||
"Principal",
|
||||
];
|
||||
|
||||
const emptyRule: EditingRule = {
|
||||
chapter: "",
|
||||
location: "",
|
||||
level: "",
|
||||
costMultiplier: 1.0,
|
||||
billMultiplier: 1.0,
|
||||
shoringRatio: "",
|
||||
additionalEffortRatio: "",
|
||||
description: "",
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
const emptySet: EditingSet = {
|
||||
name: "",
|
||||
description: "",
|
||||
isDefault: false,
|
||||
rules: [],
|
||||
};
|
||||
|
||||
export function ExperienceMultipliersClient() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||
|
||||
const createMutation = trpc.experienceMultiplier.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.experienceMultiplier.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
});
|
||||
const updateMutation = trpc.experienceMultiplier.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.experienceMultiplier.list.invalidate();
|
||||
setEditing(null);
|
||||
},
|
||||
});
|
||||
const deleteMutation = trpc.experienceMultiplier.delete.useMutation({
|
||||
onSuccess: () => utils.experienceMultiplier.list.invalidate(),
|
||||
});
|
||||
|
||||
const [editing, setEditing] = useState<EditingSet | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
const payload = {
|
||||
name: editing.name,
|
||||
description: editing.description || undefined,
|
||||
isDefault: editing.isDefault,
|
||||
rules: editing.rules.map((r, i) => ({
|
||||
...(r.chapter ? { chapter: r.chapter } : {}),
|
||||
...(r.location ? { location: r.location } : {}),
|
||||
...(r.level ? { level: r.level } : {}),
|
||||
costMultiplier: r.costMultiplier,
|
||||
billMultiplier: r.billMultiplier,
|
||||
...(r.shoringRatio !== "" ? { shoringRatio: parseFloat(r.shoringRatio) } : {}),
|
||||
...(r.additionalEffortRatio !== "" ? { additionalEffortRatio: parseFloat(r.additionalEffortRatio) } : {}),
|
||||
...(r.description ? { description: r.description } : {}),
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
|
||||
if (editing.id) {
|
||||
updateMutation.mutate({ id: editing.id, ...payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(set: NonNullable<typeof sets>[number]) {
|
||||
setEditing({
|
||||
id: set.id,
|
||||
name: set.name,
|
||||
description: set.description ?? "",
|
||||
isDefault: set.isDefault,
|
||||
rules: set.rules.map((r) => ({
|
||||
id: r.id,
|
||||
chapter: r.chapter ?? "",
|
||||
location: r.location ?? "",
|
||||
level: r.level ?? "",
|
||||
costMultiplier: r.costMultiplier,
|
||||
billMultiplier: r.billMultiplier,
|
||||
shoringRatio: r.shoringRatio != null ? String(r.shoringRatio) : "",
|
||||
additionalEffortRatio: r.additionalEffortRatio != null ? String(r.additionalEffortRatio) : "",
|
||||
description: r.description ?? "",
|
||||
sortOrder: r.sortOrder,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: [...editing.rules, { ...emptyRule, sortOrder: editing.rules.length }],
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: editing.rules.filter((_, i) => i !== index),
|
||||
});
|
||||
}
|
||||
|
||||
function updateRule(index: number, updates: Partial<EditingRule>) {
|
||||
if (!editing) return;
|
||||
setEditing({
|
||||
...editing,
|
||||
rules: editing.rules.map((r, i) => (i === index ? { ...r, ...updates } : r)),
|
||||
});
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Experience Multipliers</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Define rate and effort adjustments by chapter, location, and experience level.
|
||||
</p>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing({ ...emptySet })}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
New multiplier set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{editing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{editing.id ? "Edit multiplier set" : "New multiplier set"}
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
<input
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="e.g. CGI Standard Multipliers"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 uppercase">Description</label>
|
||||
<input
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
className="w-full rounded-xl border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.isDefault}
|
||||
onChange={(e) => setEditing({ ...editing, isDefault: e.target.checked })}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Default set (auto-selected when applying multipliers)
|
||||
</label>
|
||||
|
||||
{/* Rules table */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Rules ({editing.rules.length})</h3>
|
||||
<button
|
||||
onClick={addRule}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing.rules.length === 0 ? (
|
||||
<p className="rounded-xl bg-gray-50 p-4 text-center text-sm text-gray-400">
|
||||
No rules yet. Add rules to define rate and effort adjustments.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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="pl-2 py-2 font-medium w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{editing.rules.map((rule, i) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
value={rule.chapter}
|
||||
onChange={(e) => updateRule(i, { chapter: e.target.value })}
|
||||
list="chapter-presets"
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
placeholder="Any"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={rule.location}
|
||||
onChange={(e) => updateRule(i, { location: e.target.value })}
|
||||
list="location-presets"
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
placeholder="Any"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
value={rule.level}
|
||||
onChange={(e) => updateRule(i, { level: e.target.value })}
|
||||
list="level-presets"
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1 text-sm"
|
||||
placeholder="Any"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rule.costMultiplier}
|
||||
onChange={(e) => updateRule(i, { costMultiplier: parseFloat(e.target.value) || 0 })}
|
||||
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rule.billMultiplier}
|
||||
onChange={(e) => updateRule(i, { billMultiplier: parseFloat(e.target.value) || 0 })}
|
||||
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={rule.shoringRatio}
|
||||
onChange={(e) => updateRule(i, { shoringRatio: e.target.value })}
|
||||
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={rule.additionalEffortRatio}
|
||||
onChange={(e) => updateRule(i, { additionalEffortRatio: e.target.value })}
|
||||
className="w-20 rounded-lg border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
<td className="pl-2 py-2">
|
||||
<button
|
||||
onClick={() => removeRule(i)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Remove"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<datalist id="chapter-presets">
|
||||
{CHAPTER_PRESETS.map((d) => (
|
||||
<option key={d} value={d} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="location-presets">
|
||||
{LOCATION_PRESETS.map((d) => (
|
||||
<option key={d} value={d} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="level-presets">
|
||||
{LEVEL_PRESETS.map((d) => (
|
||||
<option key={d} value={d} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !editing.name.trim()}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(createMutation.error || updateMutation.error) && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{createMutation.error?.message || updateMutation.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{isLoading && <p className="text-center text-sm text-gray-400">Loading...</p>}
|
||||
|
||||
{sets && sets.length === 0 && !editing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
||||
No experience multiplier sets yet. Create one to define rate and effort adjustments.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sets?.map((s) => (
|
||||
<div key={s.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold text-gray-900">{s.name}</h3>
|
||||
{s.isDefault && (
|
||||
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-700">Default</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-500">{s.rules.length} rules</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{expandedId === s.id ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(s)}
|
||||
className="rounded-xl border border-gray-300 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete multiplier set "${s.name}"?`)) {
|
||||
deleteMutation.mutate({ id: s.id });
|
||||
}
|
||||
}}
|
||||
className="rounded-xl border border-red-200 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.description && <p className="mt-1 text-sm text-gray-500">{s.description}</p>}
|
||||
|
||||
{expandedId === s.id && s.rules.length > 0 && (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.rules.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 text-gray-900">{r.chapter || "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{r.location || "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{r.level || "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.costMultiplier.toFixed(2)}x</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{r.billMultiplier.toFixed(2)}x</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-500">
|
||||
{r.shoringRatio != null ? `${(r.shoringRatio * 100).toFixed(0)}%` : "\u2014"}
|
||||
</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">
|
||||
{r.additionalEffortRatio != null ? `${(r.additionalEffortRatio * 100).toFixed(0)}%` : "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type LevelRow = { id: string; name: string; groupId: string };
|
||||
type GroupRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
targetPercentage: number;
|
||||
sortOrder: number;
|
||||
levels: LevelRow[];
|
||||
};
|
||||
|
||||
type EditingGroup = {
|
||||
id?: string;
|
||||
name: string;
|
||||
targetPercentage: number;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type EditingLevel = {
|
||||
id?: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
};
|
||||
|
||||
export function ManagementLevelsClient() {
|
||||
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
|
||||
const [editingLevel, setEditingLevel] = useState<EditingLevel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: groups, isLoading } = trpc.managementLevel.listGroups.useQuery();
|
||||
|
||||
const createGroupMut = trpc.managementLevel.createGroup.useMutation({
|
||||
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateGroupMut = trpc.managementLevel.updateGroup.useMutation({
|
||||
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingGroup(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const createLevelMut = trpc.managementLevel.createLevel.useMutation({
|
||||
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateLevelMut = trpc.managementLevel.updateLevel.useMutation({
|
||||
onSuccess: () => { void utils.managementLevel.listGroups.invalidate(); setEditingLevel(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const deleteLevelMut = trpc.managementLevel.deleteLevel.useMutation({
|
||||
onSuccess: () => void utils.managementLevel.listGroups.invalidate(),
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function openCreateGroup() {
|
||||
const maxOrder = Math.max(0, ...(groups ?? []).map((g) => (g as unknown as GroupRow).sortOrder));
|
||||
setEditingGroup({ name: "", targetPercentage: 0, sortOrder: maxOrder + 1 });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEditGroup(g: GroupRow) {
|
||||
setEditingGroup({ id: g.id, name: g.name, targetPercentage: g.targetPercentage, sortOrder: g.sortOrder });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSaveGroup() {
|
||||
if (!editingGroup) return;
|
||||
setError(null);
|
||||
if (editingGroup.id) {
|
||||
updateGroupMut.mutate({
|
||||
id: editingGroup.id,
|
||||
data: { name: editingGroup.name, targetPercentage: editingGroup.targetPercentage, sortOrder: editingGroup.sortOrder },
|
||||
});
|
||||
} else {
|
||||
createGroupMut.mutate({
|
||||
name: editingGroup.name,
|
||||
targetPercentage: editingGroup.targetPercentage,
|
||||
sortOrder: editingGroup.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateLevel(groupId: string) {
|
||||
setEditingLevel({ name: "", groupId });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEditLevel(l: LevelRow) {
|
||||
setEditingLevel({ id: l.id, name: l.name, groupId: l.groupId });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSaveLevel() {
|
||||
if (!editingLevel) return;
|
||||
setError(null);
|
||||
if (editingLevel.id) {
|
||||
updateLevelMut.mutate({
|
||||
id: editingLevel.id,
|
||||
data: { name: editingLevel.name, groupId: editingLevel.groupId },
|
||||
});
|
||||
} else {
|
||||
createLevelMut.mutate({ name: editingLevel.name, groupId: editingLevel.groupId });
|
||||
}
|
||||
}
|
||||
|
||||
const isGroupPending = createGroupMut.isPending || updateGroupMut.isPending;
|
||||
const isLevelPending = createLevelMut.isPending || updateLevelMut.isPending;
|
||||
const rows = (groups ?? []) as unknown as GroupRow[];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateGroup}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
|
||||
|
||||
<div className="space-y-4">
|
||||
{rows.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Group header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{group.name}</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateLevel(group.id)}
|
||||
className="text-xs text-green-600 hover:text-green-800 font-medium"
|
||||
>
|
||||
+ Level
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditGroup(group)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Levels */}
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{group.levels.length === 0 && (
|
||||
<div className="px-4 py-3 text-sm text-gray-400">No levels in this group yet.</div>
|
||||
)}
|
||||
{group.levels.map((level) => (
|
||||
<div key={level.id} className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{level.name}</span>
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditLevel(level)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete level "${level.name}"?`)) {
|
||||
deleteLevelMut.mutate({ id: level.id });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">No management level groups yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Group Modal */}
|
||||
{editingGroup && (
|
||||
<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-md mx-4">
|
||||
<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">
|
||||
{editingGroup.id ? "Edit Group" : "Add Group"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingGroup(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingGroup.name}
|
||||
onChange={(e) => setEditingGroup({ ...editingGroup, name: e.target.value })}
|
||||
placeholder="e.g. Senior Management"
|
||||
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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(editingGroup.targetPercentage * 100)}
|
||||
onChange={(e) => setEditingGroup({ ...editingGroup, targetPercentage: (parseInt(e.target.value) || 0) / 100 })}
|
||||
min={0}
|
||||
max={100}
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingGroup.sortOrder}
|
||||
onChange={(e) => setEditingGroup({ ...editingGroup, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingGroup(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={handleSaveGroup}
|
||||
disabled={isGroupPending || !editingGroup.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isGroupPending ? "Saving..." : editingGroup.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level Modal */}
|
||||
{editingLevel && (
|
||||
<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-sm mx-4">
|
||||
<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">
|
||||
{editingLevel.id ? "Edit Level" : "Add Level"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingLevel(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLevel.name}
|
||||
onChange={(e) => setEditingLevel({ ...editingLevel, name: e.target.value })}
|
||||
placeholder="e.g. Managing Director"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Group</label>
|
||||
<select
|
||||
value={editingLevel.groupId}
|
||||
onChange={(e) => setEditingLevel({ ...editingLevel, groupId: 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"
|
||||
>
|
||||
{rows.map((g) => (
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingLevel(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={handleSaveLevel}
|
||||
disabled={isLevelPending || !editingLevel.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type OrgUnitRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string | null;
|
||||
level: number;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type OrgUnitNode = OrgUnitRow & { children: OrgUnitNode[] };
|
||||
|
||||
type EditingUnit = {
|
||||
id?: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
level: number;
|
||||
parentId: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
const LEVEL_LABELS: Record<number, string> = { 5: "L5 — Division", 6: "L6 — Department", 7: "L7 — Team" };
|
||||
const LEVEL_COLORS: Record<number, string> = {
|
||||
5: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
|
||||
6: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
||||
7: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
|
||||
};
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
onEdit,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: OrgUnitNode;
|
||||
onEdit: (u: OrgUnitRow) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
|
||||
style={{ paddingLeft: `${depth * 24 + 12}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
{expanded ? "▼" : "▶"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[node.level] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
L{node.level}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
|
||||
{node.name}
|
||||
{node.shortName && <span className="text-gray-400 ml-1">({node.shortName})</span>}
|
||||
</span>
|
||||
{!node.isActive && (
|
||||
<span className="text-xs text-gray-400 italic">inactive</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(node)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{expanded && node.children.map((child) => (
|
||||
<TreeNode key={child.id} node={child} onEdit={onEdit} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrgUnitsClient() {
|
||||
const [editing, setEditing] = useState<EditingUnit | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: tree, isLoading } = trpc.orgUnit.getTree.useQuery();
|
||||
const { data: flatList } = trpc.orgUnit.list.useQuery();
|
||||
|
||||
const createMut = trpc.orgUnit.create.useMutation({
|
||||
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateMut = trpc.orgUnit.update.useMutation({
|
||||
onSuccess: () => { void utils.orgUnit.getTree.invalidate(); void utils.orgUnit.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
const allUnits = (flatList ?? []) as unknown as OrgUnitRow[];
|
||||
|
||||
function openCreate(level: number, parentId?: string) {
|
||||
setEditing({
|
||||
name: "",
|
||||
shortName: "",
|
||||
level,
|
||||
parentId: parentId ?? "",
|
||||
sortOrder: 0,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEdit(u: OrgUnitRow) {
|
||||
setEditing({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
shortName: u.shortName ?? "",
|
||||
level: u.level,
|
||||
parentId: u.parentId ?? "",
|
||||
sortOrder: u.sortOrder,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
if (editing.id) {
|
||||
updateMut.mutate({
|
||||
id: editing.id,
|
||||
data: {
|
||||
name: editing.name,
|
||||
shortName: editing.shortName || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
parentId: editing.parentId || undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
name: editing.name,
|
||||
shortName: editing.shortName || undefined,
|
||||
level: editing.level,
|
||||
parentId: editing.parentId || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Possible parents for the editing unit (must be lower level number)
|
||||
const possibleParents = editing
|
||||
? allUnits.filter((u) => u.level < editing.level && u.isActive)
|
||||
: [];
|
||||
|
||||
const isPending = createMut.isPending || updateMut.isPending;
|
||||
const treeNodes = (tree ?? []) as unknown as OrgUnitNode[];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => openCreate(5)} className="px-3 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium">+ L5</button>
|
||||
<button type="button" onClick={() => openCreate(6)} className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium">+ L6</button>
|
||||
<button type="button" onClick={() => openCreate(7)} className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium">+ L7</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
|
||||
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
|
||||
{!isLoading && treeNodes.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">No org units yet. Start by adding an L5 division.</div>
|
||||
)}
|
||||
{treeNodes.map((node) => (
|
||||
<TreeNode key={node.id} node={node} onEdit={openEdit} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{editing && (
|
||||
<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-md mx-4">
|
||||
<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">
|
||||
{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
placeholder="e.g. Content Production"
|
||||
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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.shortName}
|
||||
onChange={(e) => setEditing({ ...editing, shortName: e.target.value })}
|
||||
placeholder="CP"
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{editing.level > 5 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Unit</label>
|
||||
<select
|
||||
value={editing.parentId}
|
||||
onChange={(e) => setEditing({ ...editing, parentId: 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"
|
||||
>
|
||||
<option value="">— No parent —</option>
|
||||
{possibleParents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
L{p.level} — {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${LEVEL_COLORS[editing.level] ?? "bg-gray-100 text-gray-600"}`}>
|
||||
{LEVEL_LABELS[editing.level] ?? `Level ${editing.level}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditing(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={handleSave}
|
||||
disabled={isPending || !editing.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────────────────
|
||||
|
||||
type RateCardRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
effectiveFrom: string | null;
|
||||
effectiveTo: string | null;
|
||||
source: string | null;
|
||||
isActive: boolean;
|
||||
clientId: string | null;
|
||||
client: { id: string; name: string; code: string | null } | null;
|
||||
_count: { lines: number };
|
||||
};
|
||||
|
||||
type RateCardLine = {
|
||||
id: string;
|
||||
rateCardId: string;
|
||||
roleId: string | null;
|
||||
chapter: string | null;
|
||||
location: string | null;
|
||||
seniority: string | null;
|
||||
workType: string | null;
|
||||
serviceGroup: string | null;
|
||||
costRateCents: number;
|
||||
billRateCents: number | null;
|
||||
machineRateCents: number | null;
|
||||
attributes: unknown;
|
||||
role: { id: string; name: string; color: string | null } | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ClientOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
};
|
||||
|
||||
type EditingCard = {
|
||||
id?: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string;
|
||||
source: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
type EditingLine = {
|
||||
id?: string;
|
||||
roleId: string;
|
||||
chapter: string;
|
||||
location: string;
|
||||
seniority: string;
|
||||
workType: string;
|
||||
serviceGroup: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
machineRateCents: number;
|
||||
};
|
||||
|
||||
const emptyCard: EditingCard = {
|
||||
name: "",
|
||||
currency: "EUR",
|
||||
effectiveFrom: "",
|
||||
effectiveTo: "",
|
||||
source: "",
|
||||
clientId: "",
|
||||
};
|
||||
|
||||
const emptyLine: EditingLine = {
|
||||
roleId: "",
|
||||
chapter: "",
|
||||
location: "",
|
||||
seniority: "",
|
||||
workType: "",
|
||||
serviceGroup: "",
|
||||
costRateCents: 0,
|
||||
billRateCents: 0,
|
||||
machineRateCents: 0,
|
||||
};
|
||||
|
||||
function formatCents(cents: number | null | undefined): string {
|
||||
if (cents == null) return "-";
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function formatDate(d: string | null | undefined): string {
|
||||
if (!d) return "-";
|
||||
return new Date(d).toLocaleDateString("de-DE");
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function RateCardsClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterClientId, setFilterClientId] = useState<string>("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
|
||||
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────────────────────
|
||||
|
||||
const { data: cards, isLoading } = trpc.rateCard.list.useQuery(
|
||||
{
|
||||
search: search || undefined,
|
||||
...(filterClientId ? { clientId: filterClientId } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const { data: detail } = trpc.rateCard.getById.useQuery(
|
||||
{ id: selectedId! },
|
||||
{ enabled: !!selectedId },
|
||||
);
|
||||
|
||||
const { data: roles } = trpc.role.list.useQuery({});
|
||||
const { data: clientsData } = trpc.clientEntity.list.useQuery({});
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────────────────────
|
||||
|
||||
const invalidateAll = () => {
|
||||
void utils.rateCard.list.invalidate();
|
||||
if (selectedId) void utils.rateCard.getById.invalidate({ id: selectedId });
|
||||
};
|
||||
|
||||
// Use bare useMutation() to avoid TS2589 deep inference (see LEARNINGS.md)
|
||||
const createMut = trpc.rateCard.create.useMutation();
|
||||
const updateMut = trpc.rateCard.update.useMutation();
|
||||
const deactivateMut = trpc.rateCard.deactivate.useMutation();
|
||||
const addLineMut = trpc.rateCard.addLine.useMutation();
|
||||
const updateLineMut = trpc.rateCard.updateLine.useMutation();
|
||||
const deleteLineMut = trpc.rateCard.deleteLine.useMutation();
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
function openCreateCard() {
|
||||
setEditingCard({ ...emptyCard });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEditCard() {
|
||||
if (!detail) return;
|
||||
setEditingCard({
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
currency: detail.currency,
|
||||
effectiveFrom: detail.effectiveFrom
|
||||
? new Date(detail.effectiveFrom).toISOString().slice(0, 10)
|
||||
: "",
|
||||
effectiveTo: detail.effectiveTo
|
||||
? new Date(detail.effectiveTo).toISOString().slice(0, 10)
|
||||
: "",
|
||||
source: detail.source ?? "",
|
||||
clientId: detail.clientId ?? "",
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSaveCard() {
|
||||
if (!editingCard) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingCard.id) {
|
||||
await updateMut.mutateAsync({
|
||||
id: editingCard.id,
|
||||
data: {
|
||||
name: editingCard.name,
|
||||
currency: editingCard.currency,
|
||||
...(editingCard.effectiveFrom
|
||||
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
|
||||
: { effectiveFrom: null }),
|
||||
...(editingCard.effectiveTo
|
||||
? { effectiveTo: new Date(editingCard.effectiveTo) }
|
||||
: { effectiveTo: null }),
|
||||
source: editingCard.source || null,
|
||||
clientId: editingCard.clientId || null,
|
||||
},
|
||||
});
|
||||
invalidateAll();
|
||||
setEditingCard(null);
|
||||
} else {
|
||||
const created = await createMut.mutateAsync({
|
||||
name: editingCard.name,
|
||||
currency: editingCard.currency,
|
||||
...(editingCard.effectiveFrom
|
||||
? { effectiveFrom: new Date(editingCard.effectiveFrom) }
|
||||
: {}),
|
||||
...(editingCard.effectiveTo
|
||||
? { effectiveTo: new Date(editingCard.effectiveTo) }
|
||||
: {}),
|
||||
...(editingCard.source ? { source: editingCard.source } : {}),
|
||||
...(editingCard.clientId ? { clientId: editingCard.clientId } : {}),
|
||||
lines: [],
|
||||
});
|
||||
invalidateAll();
|
||||
setEditingCard(null);
|
||||
setSelectedId(created.id);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save rate card");
|
||||
}
|
||||
}
|
||||
|
||||
function openAddLine() {
|
||||
setEditingLine({ ...emptyLine });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEditLine(line: RateCardLine) {
|
||||
setEditingLine({
|
||||
id: line.id,
|
||||
roleId: line.roleId ?? "",
|
||||
chapter: line.chapter ?? "",
|
||||
location: line.location ?? "",
|
||||
seniority: line.seniority ?? "",
|
||||
workType: line.workType ?? "",
|
||||
serviceGroup: line.serviceGroup ?? "",
|
||||
costRateCents: line.costRateCents,
|
||||
billRateCents: line.billRateCents ?? 0,
|
||||
machineRateCents: line.machineRateCents ?? 0,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSaveLine() {
|
||||
if (!editingLine || !selectedId) return;
|
||||
setError(null);
|
||||
|
||||
const lineData = {
|
||||
...(editingLine.roleId ? { roleId: editingLine.roleId } : {}),
|
||||
...(editingLine.chapter ? { chapter: editingLine.chapter } : {}),
|
||||
...(editingLine.location ? { location: editingLine.location } : {}),
|
||||
...(editingLine.seniority ? { seniority: editingLine.seniority } : {}),
|
||||
...(editingLine.workType ? { workType: editingLine.workType } : {}),
|
||||
...(editingLine.serviceGroup ? { serviceGroup: editingLine.serviceGroup } : {}),
|
||||
costRateCents: editingLine.costRateCents,
|
||||
...(editingLine.billRateCents ? { billRateCents: editingLine.billRateCents } : {}),
|
||||
...(editingLine.machineRateCents ? { machineRateCents: editingLine.machineRateCents } : {}),
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingLine.id) {
|
||||
await updateLineMut.mutateAsync({ lineId: editingLine.id, data: lineData });
|
||||
} else {
|
||||
await addLineMut.mutateAsync({ rateCardId: selectedId, line: lineData });
|
||||
}
|
||||
invalidateAll();
|
||||
setEditingLine(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save rate line");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLine(lineId: string) {
|
||||
if (!confirm("Delete this rate line?")) return;
|
||||
try {
|
||||
await deleteLineMut.mutateAsync({ lineId });
|
||||
invalidateAll();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete rate line");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate(id: string) {
|
||||
if (!confirm("Deactivate this rate card?")) return;
|
||||
try {
|
||||
await deactivateMut.mutateAsync({ id });
|
||||
invalidateAll();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to deactivate rate card");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReactivate(id: string) {
|
||||
try {
|
||||
await updateMut.mutateAsync({ id, data: { isActive: true } });
|
||||
invalidateAll();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to reactivate rate card");
|
||||
}
|
||||
}
|
||||
|
||||
const isPending =
|
||||
createMut.isPending ||
|
||||
updateMut.isPending ||
|
||||
addLineMut.isPending ||
|
||||
updateLineMut.isPending ||
|
||||
deleteLineMut.isPending;
|
||||
|
||||
const cardList = (cards ?? []) as unknown as RateCardRow[];
|
||||
const lines = ((detail?.lines ?? []) as unknown as RateCardLine[]);
|
||||
const roleList = (roles ?? []) as unknown as { id: string; name: string; color: string | null }[];
|
||||
const clientList = (clientsData ?? []) as unknown as ClientOption[];
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Rate Cards</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage cost and billing rates per role, chapter, and seniority
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateCard}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ New Rate Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* ─── Left: Card list ─────────────────────────────────────────── */}
|
||||
<div className="w-80 shrink-0">
|
||||
<div className="mb-3 space-y-2">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search rate cards..."
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<select
|
||||
value={filterClientId}
|
||||
onChange={(e) => setFilterClientId(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All clients</option>
|
||||
{clientList.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code ? `${c.code} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
|
||||
{!isLoading && cardList.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
{search ? "No rate cards match your search." : "No rate cards yet."}
|
||||
</div>
|
||||
)}
|
||||
{cardList.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(card.id)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors ${
|
||||
selectedId === card.id
|
||||
? "bg-brand-50 dark:bg-brand-900/20 border-l-2 border-brand-600"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{card.name}
|
||||
</span>
|
||||
{!card.isActive && (
|
||||
<span className="text-xs text-gray-400 italic ml-2 shrink-0">inactive</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{card.currency}</span>
|
||||
<span>{card._count.lines} lines</span>
|
||||
{card.effectiveFrom && (
|
||||
<span>from {formatDate(card.effectiveFrom)}</span>
|
||||
)}
|
||||
</div>
|
||||
{card.client && (
|
||||
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
|
||||
{card.client.code ? `${card.client.code} — ${card.client.name}` : card.client.name}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Right: Detail ───────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{!selectedId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center text-gray-400">
|
||||
Select a rate card to view details, or create a new one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && detail && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
{/* Card header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{detail.currency}</span>
|
||||
{detail.clientId && (detail as unknown as RateCardRow).client && (
|
||||
<span>Client: {(detail as unknown as RateCardRow).client!.name}</span>
|
||||
)}
|
||||
{detail.source && <span>Source: {detail.source}</span>}
|
||||
<span>
|
||||
{detail.effectiveFrom
|
||||
? formatDate(detail.effectiveFrom as unknown as string)
|
||||
: "Open start"}{" "}
|
||||
—{" "}
|
||||
{detail.effectiveTo
|
||||
? formatDate(detail.effectiveTo as unknown as string)
|
||||
: "Open end"}
|
||||
</span>
|
||||
{!detail.isActive && (
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditCard}
|
||||
className="px-3 py-1.5 text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{detail.isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeactivate(detail.id)}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReactivate(detail.id)}
|
||||
className="px-3 py-1.5 text-sm text-green-600 hover:text-green-800 font-medium"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lines header */}
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Rate Lines ({lines.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddLine}
|
||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-xs font-medium"
|
||||
>
|
||||
+ Add Line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lines table */}
|
||||
<div className="overflow-x-auto">
|
||||
{lines.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
No rate lines yet. Add one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<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 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-gray-100">
|
||||
{line.role?.name ?? <span className="text-gray-400">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.chapter || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.location || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.seniority || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.workType || "-"}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-900 dark:text-gray-100">
|
||||
{formatCents(line.costRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||
{formatCents(line.billRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||
{formatCents(line.machineRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditLine(line)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteLine(line.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Rate Card Modal ─────────────────────────────────────────────── */}
|
||||
{editingCard && (
|
||||
<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-md mx-4">
|
||||
<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">
|
||||
{editingCard.id ? "Edit Rate Card" : "New Rate Card"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingCard(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.name}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, name: e.target.value })}
|
||||
placeholder="e.g. Standard 2026"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
|
||||
<select
|
||||
value={editingCard.clientId}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, clientId: 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"
|
||||
>
|
||||
<option value="">-- No client --</option>
|
||||
{clientList.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code ? `${c.code} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.currency}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, currency: e.target.value.toUpperCase() })}
|
||||
placeholder="EUR"
|
||||
maxLength={3}
|
||||
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 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.source}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, source: e.target.value })}
|
||||
placeholder="e.g. Finance dept"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveFrom}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, effectiveFrom: 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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveTo}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, effectiveTo: 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingCard(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={handleSaveCard}
|
||||
disabled={isPending || !editingCard.name || editingCard.currency.length !== 3}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editingCard.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Rate Line Modal ─────────────────────────────────────────────── */}
|
||||
{editingLine && (
|
||||
<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">
|
||||
<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">
|
||||
{editingLine.id ? "Edit Rate Line" : "Add Rate Line"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingLine(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select
|
||||
value={editingLine.roleId}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, roleId: 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"
|
||||
>
|
||||
<option value="">-- No specific role --</option>
|
||||
{roleList.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.chapter}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, chapter: e.target.value })}
|
||||
placeholder="e.g. Animation"
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.location}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, location: e.target.value })}
|
||||
placeholder="e.g. Munich"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.seniority}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, seniority: e.target.value })}
|
||||
placeholder="e.g. Senior"
|
||||
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>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.workType}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, workType: e.target.value })}
|
||||
placeholder="e.g. Onsite"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.serviceGroup}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, serviceGroup: e.target.value })}
|
||||
placeholder="e.g. Post Production"
|
||||
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>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.costRateCents}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, costRateCents: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
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 font-mono"
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.billRateCents}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, billRateCents: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
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 font-mono"
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.machineRateCents}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, machineRateCents: parseInt(e.target.value) || 0 })}
|
||||
min={0}
|
||||
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 font-mono"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.machineRateCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingLine(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={handleSaveLine}
|
||||
disabled={isPending || editingLine.costRateCents <= 0}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editingLine.id ? "Update Line" : "Add Line"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLASS =
|
||||
"w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100";
|
||||
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
type Provider = "openai" | "azure";
|
||||
|
||||
const ALL_ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
|
||||
type SystemRole = typeof ALL_ROLES[number];
|
||||
|
||||
interface ScoreWeights {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
}
|
||||
|
||||
type ParsedAzureUrl = {
|
||||
endpoint: string;
|
||||
apiVersion: string;
|
||||
deployment: string | null; // null for Responses API URLs (deployment not in path)
|
||||
urlType: "completions" | "responses";
|
||||
};
|
||||
|
||||
/** Parse endpoint, deployment, and api-version out of an Azure URL.
|
||||
* Supports both Chat Completions and Responses API formats. */
|
||||
function parseAzureUrl(raw: string): ParsedAzureUrl | null {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const endpoint = `${url.protocol}//${url.host}`;
|
||||
const apiVersion = url.searchParams.get("api-version") ?? "2025-01-01-preview";
|
||||
|
||||
// Chat Completions: /openai/deployments/{name}/chat/completions
|
||||
const completionsMatch = url.pathname.match(/\/openai\/deployments\/([^/]+)\//);
|
||||
if (completionsMatch) {
|
||||
return { endpoint, apiVersion, deployment: completionsMatch[1]!, urlType: "completions" };
|
||||
}
|
||||
|
||||
// Responses API: /openai/responses
|
||||
if (url.pathname.includes("/openai/responses")) {
|
||||
return { endpoint, apiVersion, deployment: null, urlType: "responses" };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function SystemSettingsClient() {
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [endpoint, setEndpoint] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [apiVersion, setApiVersion] = useState("2025-01-01-preview");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [maxTokens, setMaxTokens] = useState(2000);
|
||||
const [temperature, setTemperature] = useState(1);
|
||||
const [summaryPrompt, setSummaryPrompt] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string; raw?: string | null } | null>(null);
|
||||
const [urlPasteValue, setUrlPasteValue] = useState("");
|
||||
const [urlParseError, setUrlParseError] = useState(false);
|
||||
const [urlParsedType, setUrlParsedType] = useState<"completions" | "responses" | null>(null);
|
||||
|
||||
// Value Score settings
|
||||
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>({
|
||||
skillDepth: 0.30,
|
||||
skillBreadth: 0.15,
|
||||
costEfficiency: 0.25,
|
||||
chargeability: 0.15,
|
||||
experience: 0.15,
|
||||
});
|
||||
const [scoreVisibleRoles, setScoreVisibleRoles] = useState<SystemRole[]>(["ADMIN", "MANAGER"]);
|
||||
const [scoreSaved, setScoreSaved] = useState(false);
|
||||
const [recomputeResult, setRecomputeResult] = useState<{ updated: number } | null>(null);
|
||||
|
||||
// SMTP settings
|
||||
const [smtpHost, setSmtpHost] = useState("");
|
||||
const [smtpPort, setSmtpPort] = useState(587);
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPassword, setSmtpPassword] = useState("");
|
||||
const [smtpFrom, setSmtpFrom] = useState("");
|
||||
const [smtpTls, setSmtpTls] = useState(true);
|
||||
const [smtpSaved, setSmtpSaved] = useState(false);
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||
|
||||
// Vacation defaults
|
||||
const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
|
||||
const [vacationSaved, setVacationSaved] = useState(false);
|
||||
|
||||
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setProvider((settings.aiProvider ?? "openai") as Provider);
|
||||
setEndpoint(settings.azureOpenAiEndpoint ?? "");
|
||||
setModel(settings.azureOpenAiDeployment ?? "");
|
||||
setApiVersion(settings.azureApiVersion ?? "2025-01-01-preview");
|
||||
setMaxTokens(settings.aiMaxCompletionTokens ?? 2000);
|
||||
setTemperature(settings.aiTemperature ?? 1);
|
||||
setSummaryPrompt(settings.aiSummaryPrompt ?? "");
|
||||
if (settings.scoreWeights) {
|
||||
setScoreWeights(settings.scoreWeights as ScoreWeights);
|
||||
}
|
||||
if (settings.scoreVisibleRoles) {
|
||||
setScoreVisibleRoles(settings.scoreVisibleRoles as SystemRole[]);
|
||||
}
|
||||
// SMTP
|
||||
setSmtpHost(settings.smtpHost ?? "");
|
||||
setSmtpPort(settings.smtpPort ?? 587);
|
||||
setSmtpUser(settings.smtpUser ?? "");
|
||||
setSmtpFrom(settings.smtpFrom ?? "");
|
||||
setSmtpTls(settings.smtpTls ?? true);
|
||||
// Vacation
|
||||
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
function handleUrlPaste(raw: string) {
|
||||
setUrlPasteValue(raw);
|
||||
if (!raw) { setUrlParseError(false); setUrlParsedType(null); return; }
|
||||
const parsed = parseAzureUrl(raw);
|
||||
if (parsed) {
|
||||
setEndpoint(parsed.endpoint);
|
||||
setApiVersion(parsed.apiVersion);
|
||||
if (parsed.deployment) setModel(parsed.deployment);
|
||||
setUrlParseError(false);
|
||||
setUrlParsedType(parsed.urlType);
|
||||
setUrlPasteValue("");
|
||||
} else {
|
||||
setUrlParseError(true);
|
||||
setUrlParsedType(null);
|
||||
}
|
||||
}
|
||||
|
||||
const updateMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setSaved(true);
|
||||
setTestResult(null);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const testMutation = trpc.settings.testAiConnection.useMutation({
|
||||
onSuccess: (data) => setTestResult(data),
|
||||
onError: (err) => setTestResult({ ok: false, error: err.message }),
|
||||
});
|
||||
|
||||
const saveScoreMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setScoreSaved(true);
|
||||
setTimeout(() => setScoreSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const recomputeMutation = trpc.resource.recomputeValueScores.useMutation({
|
||||
onSuccess: (data) => setRecomputeResult(data),
|
||||
});
|
||||
|
||||
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setSmtpSaved(true);
|
||||
setSmtpTestResult(null);
|
||||
setTimeout(() => setSmtpSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
|
||||
onSuccess: (data) => setSmtpTestResult(data),
|
||||
onError: (err) => setSmtpTestResult({ ok: false, error: err.message }),
|
||||
});
|
||||
|
||||
const saveVacationMutation = trpc.settings.updateSystemSettings.useMutation({
|
||||
onSuccess: () => {
|
||||
setVacationSaved(true);
|
||||
setTimeout(() => setVacationSaved(false), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSaveSmtp() {
|
||||
saveSmtpMutation.mutate({
|
||||
smtpHost: smtpHost || undefined,
|
||||
smtpPort,
|
||||
smtpUser: smtpUser || undefined,
|
||||
...(smtpPassword ? { smtpPassword } : {}),
|
||||
smtpFrom: smtpFrom || undefined,
|
||||
smtpTls,
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveVacation() {
|
||||
saveVacationMutation.mutate({ vacationDefaultDays });
|
||||
}
|
||||
|
||||
function handleSaveScoreSettings() {
|
||||
saveScoreMutation.mutate({ scoreWeights, scoreVisibleRoles });
|
||||
}
|
||||
|
||||
function updateWeight(key: keyof ScoreWeights, value: number) {
|
||||
setScoreWeights((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleRole(role: SystemRole) {
|
||||
setScoreVisibleRoles((prev) =>
|
||||
prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role],
|
||||
);
|
||||
}
|
||||
|
||||
const weightSum = Object.values(scoreWeights).reduce((s, v) => s + v, 0);
|
||||
const weightSumOk = Math.abs(weightSum - 1.0) < 0.01;
|
||||
|
||||
function handleSave() {
|
||||
updateMutation.mutate({
|
||||
aiProvider: provider,
|
||||
azureOpenAiEndpoint: provider === "azure" ? endpoint : "",
|
||||
azureOpenAiDeployment: model,
|
||||
azureApiVersion: provider === "azure" ? apiVersion : undefined,
|
||||
aiMaxCompletionTokens: maxTokens,
|
||||
aiTemperature: temperature,
|
||||
aiSummaryPrompt: summaryPrompt || undefined,
|
||||
...(apiKey ? { azureOpenAiApiKey: apiKey } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 animate-pulse"><div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">System Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Configure AI integration for skill profile generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">AI Provider</h2>
|
||||
|
||||
{/* Provider toggle */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Provider</label>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-600 w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setProvider("openai"); setTestResult(null); }}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
provider === "openai"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
OpenAI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setProvider("azure"); setTestResult(null); }}
|
||||
className={`px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-gray-600 transition-colors ${
|
||||
provider === "azure"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Azure OpenAI
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
|
||||
{provider === "openai"
|
||||
? "Use a standard OpenAI API key from platform.openai.com."
|
||||
: "Use a deployment on your own Azure OpenAI resource."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Azure-only fields */}
|
||||
{provider === "azure" && (
|
||||
<>
|
||||
{/* Paste full URL shortcut */}
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 px-4 py-3 space-y-2">
|
||||
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
Paste a full completion URL to auto-fill all fields below:
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
|
||||
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
|
||||
value={urlPasteValue}
|
||||
onChange={(e) => handleUrlPaste(e.target.value)}
|
||||
/>
|
||||
{urlParseError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
Could not parse URL — expected either a Chat Completions URL
|
||||
(<code className="font-mono">/openai/deployments/…/chat/completions</code>)
|
||||
or a Responses API URL (<code className="font-mono">/openai/responses</code>).
|
||||
</p>
|
||||
)}
|
||||
{urlParsedType === "responses" && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Responses API URL detected — endpoint and api-version filled in.
|
||||
Enter the <strong>deployment/model name</strong> manually below (it is not part of this URL).
|
||||
</p>
|
||||
)}
|
||||
{urlParsedType === "completions" && (
|
||||
<p className="text-xs text-green-700 dark:text-green-400">
|
||||
All fields filled from URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-endpoint">Endpoint (base URL)</label>
|
||||
<input
|
||||
id="ai-endpoint"
|
||||
type="url"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="https://myinstance.cognitiveservices.azure.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Everything up to (not including) <code className="font-mono">/openai/…</code>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Model / deployment name */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-model">
|
||||
{provider === "azure" ? "Deployment Name" : "Model Name"}
|
||||
</label>
|
||||
<input
|
||||
id="ai-model"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{provider === "azure"
|
||||
? "The deployment name you chose when deploying the model in Azure."
|
||||
: "The model identifier, e.g. gpt-4o-mini, gpt-4o, gpt-3.5-turbo."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Azure-only: api version */}
|
||||
{provider === "azure" && (
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-api-version">API Version</label>
|
||||
<input
|
||||
id="ai-api-version"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="2025-01-01-preview"
|
||||
value={apiVersion}
|
||||
onChange={(e) => setApiVersion(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
The <code className="font-mono">api-version</code> query parameter from your endpoint URL. Default: <code className="font-mono">2025-01-01-preview</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API key */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-key">API Key</label>
|
||||
<input
|
||||
id="ai-key"
|
||||
type="password"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={
|
||||
settings?.hasApiKey
|
||||
? "●●●●●●●●●●●● (already set — enter new value to replace)"
|
||||
: provider === "openai"
|
||||
? "sk-..."
|
||||
: "Enter Azure API key"
|
||||
}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{provider === "openai"
|
||||
? "Your secret key from platform.openai.com → API keys. Starts with sk-."
|
||||
: "One of the two keys from Azure Portal → your resource → Keys and Endpoint."}
|
||||
{settings?.hasApiKey && " Leave blank to keep the existing key."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded-lg px-4 py-3 text-sm ${
|
||||
testResult.ok
|
||||
? "bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<span className="font-medium">Connection successful — AI summaries are ready to use.</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p><span className="font-medium">Connection failed:</span> {testResult.error}</p>
|
||||
{testResult.raw && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer opacity-70 hover:opacity-100">Show raw error</summary>
|
||||
<pre className="mt-1 p-2 bg-red-100 dark:bg-red-950 rounded text-red-800 dark:text-red-200 whitespace-pre-wrap break-all font-mono">
|
||||
{testResult.raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTestResult(null); testMutation.mutate(); }}
|
||||
disabled={testMutation.isPending}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generation settings */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider">Generation Settings</h2>
|
||||
|
||||
{/* Max completion tokens */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">Max Completion Tokens</label>
|
||||
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
|
||||
</div>
|
||||
<input
|
||||
id="ai-max-tokens"
|
||||
type="range"
|
||||
min={50}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(Number(e.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>500</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{maxTokens < 1000 ? "⚠ May be empty for reasoning models (GPT-5, o1, o3)" :
|
||||
maxTokens <= 2000 ? "Recommended for reasoning models ✓" :
|
||||
maxTokens <= 4000 ? "High — allows longer bios" :
|
||||
"Very high"}
|
||||
</span>
|
||||
<span>16000</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
Reasoning models (GPT-5, o1, o3) consume tokens internally before writing output. Set to at least 2000 to avoid empty responses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-temperature">Temperature</label>
|
||||
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
<input
|
||||
id="ai-temperature"
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(Number(e.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>0 — deterministic</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{temperature <= 0.3 ? "Factual & consistent" :
|
||||
temperature <= 0.9 ? "Balanced" :
|
||||
temperature <= 1.0 ? "Default (1) — recommended ✓" :
|
||||
temperature <= 1.2 ? "Creative" :
|
||||
"Very creative / unpredictable"}
|
||||
</span>
|
||||
<span>2 — creative</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Some models (e.g. GPT-5) only accept the default value of 1. If generation fails with a temperature error, the system retries automatically without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary prompt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-prompt">Profile Summary Prompt</label>
|
||||
{summaryPrompt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSummaryPrompt("")}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
id="ai-prompt"
|
||||
rows={10}
|
||||
value={summaryPrompt || (settings?.defaultSummaryPrompt ?? "")}
|
||||
onChange={(e) => setSummaryPrompt(e.target.value)}
|
||||
className={`${INPUT_CLASS} font-mono text-xs leading-relaxed resize-y`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Available placeholders: <code className="font-mono">{"{role}"}</code> <code className="font-mono">{"{chapter}"}</code> <code className="font-mono">{"{mainSkills}"}</code> <code className="font-mono">{"{topSkills}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
{saved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{/* end 2-col grid */}
|
||||
|
||||
{/* Value Score Settings */}
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 uppercase tracking-wider mb-1">Value Score</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
A persistent 0–100 <em>price/quality</em> metric per resource — five weighted dimensions combined.
|
||||
Recompute on demand after changing weights or importing new skill matrices.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 px-3 py-2 font-mono text-[11px] text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
round(<span className="text-brand-600 dark:text-brand-400">D</span>·w₁ +{" "}
|
||||
<span className="text-brand-600 dark:text-brand-400">B</span>·w₂ +{" "}
|
||||
<span className="text-brand-600 dark:text-brand-400">C</span>·w₃ +{" "}
|
||||
<span className="text-brand-600 dark:text-brand-400">A</span>·w₄ +{" "}
|
||||
<span className="text-brand-600 dark:text-brand-400">E</span>·w₅)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weight sliders — compact grid */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
Dimension Weights — must sum to 100%
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-3">
|
||||
|
||||
{/* Skill Depth */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">D</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Depth</span>
|
||||
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
|
||||
{Math.round(scoreWeights.skillDepth * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={100} step={5}
|
||||
value={Math.round(scoreWeights.skillDepth * 100)}
|
||||
onChange={(e) => updateWeight("skillDepth", Number(e.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Average proficiency (1–5) across all skills, scaled to 0–100. Expert-heavy profiles score near 100.
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
D = round((avg_proficiency / 5) × 100)
|
||||
<span className="ml-2 text-gray-400">avg 4.0/5 → 80</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
0 = all Beginner · 100 = all Expert
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Skill Breadth */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">B</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Skill Breadth</span>
|
||||
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
|
||||
{Math.round(scoreWeights.skillBreadth * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={100} step={5}
|
||||
value={Math.round(scoreWeights.skillBreadth * 100)}
|
||||
onChange={(e) => updateWeight("skillBreadth", Number(e.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Number of distinct skills listed. 10 pts per skill, caps at 100 (10+ skills). Rewards versatile generalists.
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
B = min(100, skill_count × 10)
|
||||
<span className="ml-2 text-gray-400">7 skills → 70</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
0 = no skills · 100 = 10+ skills
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Cost Efficiency */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">C</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Cost Efficiency</span>
|
||||
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
|
||||
{Math.round(scoreWeights.costEfficiency * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={100} step={5}
|
||||
value={Math.round(scoreWeights.costEfficiency * 100)}
|
||||
onChange={(e) => updateWeight("costEfficiency", Number(e.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Inverse LCR vs org-wide max. Cheapest resource = 100, most expensive = 0. Core "price" component.
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
C = round((1 − LCR / max_LCR) × 100)
|
||||
<span className="ml-2 text-gray-400">60€ vs 120€ → 50</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
0 = highest LCR · 100 = lowest LCR
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-500">
|
||||
If all resources share the same LCR, everyone scores 0 on this dimension.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Chargeability */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">A</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Chargeability</span>
|
||||
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
|
||||
{Math.round(scoreWeights.chargeability * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={100} step={5}
|
||||
value={Math.round(scoreWeights.chargeability * 100)}
|
||||
onChange={(e) => updateWeight("chargeability", Number(e.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Distance from personal chargeability target (90-day window). On target = 100; −2 pts per pp off.
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
A = max(0, 100 − |target% − actual%| × 2)
|
||||
<span className="ml-2 text-gray-400">10 pp off → 80</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
0 = 50+ pp off target · 100 = exactly on target
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
New resources with no allocations: actual = 0%, score reflects gap from target.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Experience */}
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 text-[10px] font-mono rounded font-bold">E</span>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 flex-1">Experience</span>
|
||||
<span className="text-sm font-bold font-mono text-brand-600 dark:text-brand-400 tabular-nums w-10 text-right">
|
||||
{Math.round(scoreWeights.experience * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={100} step={5}
|
||||
value={Math.round(scoreWeights.experience * 100)}
|
||||
onChange={(e) => updateWeight("experience", Number(e.target.value) / 100)}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 select-none">Show details</summary>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
Average years of experience across skills with explicit years data from skill-matrix imports. Capped at 10 years.
|
||||
</p>
|
||||
<div className="rounded bg-gray-50 dark:bg-gray-700 px-2 py-1.5 font-mono text-[11px] text-gray-600 dark:text-gray-300">
|
||||
E = min(100, avg_years × 10)
|
||||
<span className="ml-2 text-gray-400">6.5 yrs → 65 · 10+ yrs → 100</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
0 = no years data · 100 = 10+ years average
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weight sum indicator */}
|
||||
<div className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border ${
|
||||
weightSumOk
|
||||
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700 text-green-700 dark:text-green-300"
|
||||
: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700 text-red-700 dark:text-red-300"
|
||||
}`}>
|
||||
<span>{weightSumOk ? "✓" : "✗"}</span>
|
||||
<span>
|
||||
Total weight: <span className="font-mono">{Math.round(weightSum * 100)}%</span>
|
||||
{weightSumOk ? " — valid" : " — must be exactly 100% to save"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visibility roles */}
|
||||
<div className="space-y-2">
|
||||
<label className={LABEL_CLASS}>Score visibility — which roles can see the Value Score</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Controls who sees the Score column on the Resources list, the breakdown on the Resource Detail page,
|
||||
and the Top Value Resources dashboard widget.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
{ALL_ROLES.map((role) => (
|
||||
<label key={role} className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scoreVisibleRoles.includes(role)}
|
||||
onChange={() => toggleRole(role)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{role}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recompute */}
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 pt-5 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">Recompute Scores</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Scores are <strong>not updated automatically</strong> — run this after changing weights or after importing
|
||||
new skill matrices. The computation fetches all active resources and their last 90 days of allocations,
|
||||
then writes the result back to each resource record.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRecomputeResult(null); recomputeMutation.mutate(); }}
|
||||
disabled={recomputeMutation.isPending}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{recomputeMutation.isPending ? "Recomputing…" : "Recompute All Scores"}
|
||||
</button>
|
||||
{recomputeResult && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
Updated {recomputeResult.updated} resource{recomputeResult.updated !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveScoreSettings}
|
||||
disabled={saveScoreMutation.isPending || !weightSumOk}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saveScoreMutation.isPending ? "Saving…" : "Save Score Settings"}
|
||||
</button>
|
||||
{scoreSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── SMTP / Email ──────────────────────────────────────────── */}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Email Notifications (SMTP)</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Host</label>
|
||||
<input type="text" className={INPUT_CLASS} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Port</label>
|
||||
<input type="number" className={INPUT_CLASS} value={smtpPort} onChange={(e) => setSmtpPort(parseInt(e.target.value, 10))} min={1} max={65535} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>SMTP Username</label>
|
||||
<input type="text" className={INPUT_CLASS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} placeholder="user@example.com" autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
SMTP Password{" "}
|
||||
{settings?.hasSmtpPassword && <span className="text-gray-400 font-normal text-xs">(set — leave blank to keep)</span>}
|
||||
</label>
|
||||
<input type="password" className={INPUT_CLASS} value={smtpPassword} onChange={(e) => setSmtpPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>From Address</label>
|
||||
<input type="email" className={INPUT_CLASS} value={smtpFrom} onChange={(e) => setSmtpFrom(e.target.value)} placeholder="noreply@planarchy.app" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="smtpTls" checked={smtpTls} onChange={(e) => setSmtpTls(e.target.checked)} className="rounded border-gray-300 text-brand-600" />
|
||||
<label htmlFor="smtpTls" className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Use TLS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveSmtp}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saveSmtpMutation.isPending ? "Saving…" : "Save SMTP Settings"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => testSmtpMutation.mutate()}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{testSmtpMutation.isPending ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
{smtpSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
|
||||
{smtpTestResult && (
|
||||
<span className={`text-sm font-medium ${smtpTestResult.ok ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
|
||||
{smtpTestResult.ok ? "✓ Connection successful" : `✗ ${smtpTestResult.error}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Vacation Defaults ─────────────────────────────────────── */}
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Vacation Defaults</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<label className={LABEL_CLASS}>Default Annual Leave Days</label>
|
||||
<input
|
||||
type="number"
|
||||
className={INPUT_CLASS}
|
||||
value={vacationDefaultDays}
|
||||
onChange={(e) => setVacationDefaultDays(parseInt(e.target.value, 10))}
|
||||
min={0}
|
||||
max={365}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">Applied when creating new entitlement records for resources.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveVacation}
|
||||
disabled={saveVacationMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{saveVacationMutation.isPending ? "Saving…" : "Save Vacation Settings"}
|
||||
</button>
|
||||
{vacationSaved && <span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.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",
|
||||
};
|
||||
|
||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||
[SystemRole.ADMIN]: "Admin",
|
||||
[SystemRole.MANAGER]: "Manager",
|
||||
[SystemRole.CONTROLLER]: "Controller",
|
||||
[SystemRole.USER]: "User",
|
||||
[SystemRole.VIEWER]: "Viewer",
|
||||
};
|
||||
|
||||
const ROLE_BADGE_COLORS: Record<SystemRole, string> = {
|
||||
[SystemRole.ADMIN]: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
|
||||
[SystemRole.MANAGER]: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
||||
[SystemRole.CONTROLLER]: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
||||
[SystemRole.USER]: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
[SystemRole.VIEWER]: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
|
||||
};
|
||||
|
||||
// Lower = more privileged (sort asc = most privileged first)
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
ADMIN: 0,
|
||||
MANAGER: 1,
|
||||
CONTROLLER: 2,
|
||||
USER: 3,
|
||||
VIEWER: 4,
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
systemRole: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type EditState = {
|
||||
userId: string;
|
||||
systemRole: SystemRole;
|
||||
granted: Set<string>;
|
||||
denied: Set<string>;
|
||||
chapterIds: string;
|
||||
};
|
||||
|
||||
export function UsersClient() {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [editState, setEditState] = useState<EditState | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: users, isLoading } = trpc.user.list.useQuery(undefined, {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
|
||||
{ userId: selectedUserId ?? "" },
|
||||
{ enabled: !!selectedUserId },
|
||||
);
|
||||
|
||||
const updateRoleMutation = trpc.user.updateRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.list.invalidate();
|
||||
await utils.user.getEffectivePermissions.invalidate();
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for nullable overrides schema
|
||||
const setPermissionsMutation = trpc.user.setPermissions.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.list.invalidate();
|
||||
await utils.user.getEffectivePermissions.invalidate();
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
const resetPermissionsMutation = trpc.user.resetPermissions.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.user.list.invalidate();
|
||||
await utils.user.getEffectivePermissions.invalidate();
|
||||
if (editState) {
|
||||
setEditState({ ...editState, granted: new Set(), denied: new Set(), chapterIds: "" });
|
||||
}
|
||||
},
|
||||
onError: (err) => setActionError(err.message),
|
||||
});
|
||||
|
||||
function openEdit(user: UserRow) {
|
||||
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
||||
setSelectedUserId(user.id);
|
||||
setEditState({
|
||||
userId: user.id,
|
||||
systemRole: role,
|
||||
granted: new Set(),
|
||||
denied: new Set(),
|
||||
chapterIds: "",
|
||||
});
|
||||
setActionError(null);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
setSelectedUserId(null);
|
||||
setEditState(null);
|
||||
setActionError(null);
|
||||
}
|
||||
|
||||
function toggleGranted(key: string) {
|
||||
if (!editState) return;
|
||||
const next = new Set(editState.granted);
|
||||
const nextDenied = new Set(editState.denied);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
nextDenied.delete(key);
|
||||
}
|
||||
setEditState({ ...editState, granted: next, denied: nextDenied });
|
||||
}
|
||||
|
||||
function toggleDenied(key: string) {
|
||||
if (!editState) return;
|
||||
const next = new Set(editState.denied);
|
||||
const nextGranted = new Set(editState.granted);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
nextGranted.delete(key);
|
||||
}
|
||||
setEditState({ ...editState, denied: next, granted: nextGranted });
|
||||
}
|
||||
|
||||
async function handleSaveRole() {
|
||||
if (!editState) return;
|
||||
setActionError(null);
|
||||
await updateRoleMutation.mutateAsync({ id: editState.userId, systemRole: editState.systemRole });
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
if (!editState) return;
|
||||
setActionError(null);
|
||||
const granted = Array.from(editState.granted);
|
||||
const denied = Array.from(editState.denied);
|
||||
const chapterIds = editState.chapterIds
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const overrides: PermissionOverrides = {
|
||||
...(granted.length > 0 ? { granted: granted as unknown as PermissionKey[] } : {}),
|
||||
...(denied.length > 0 ? { denied: denied as unknown as PermissionKey[] } : {}),
|
||||
...(chapterIds.length > 0 ? { chapterIds } : {}),
|
||||
};
|
||||
const hasOverrides = granted.length > 0 || denied.length > 0 || chapterIds.length > 0;
|
||||
await setPermissionsMutation.mutateAsync({
|
||||
userId: editState.userId,
|
||||
overrides: hasOverrides ? overrides : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!editState) return;
|
||||
setActionError(null);
|
||||
await resetPermissionsMutation.mutateAsync({ userId: editState.userId });
|
||||
}
|
||||
|
||||
const allUsers = (users ?? []) as unknown as UserRow[];
|
||||
|
||||
// Client-side filtering
|
||||
const filteredUsers = allUsers.filter((u) => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (!(u.name ?? "").toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (roleFilter && u.systemRole !== roleFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const usersViewPrefs = useViewPrefs("users");
|
||||
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredUsers, {
|
||||
initialField: usersViewPrefs.savedSort?.field ?? null,
|
||||
initialDir: usersViewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
usersViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSort(field: string) {
|
||||
if (field === "systemRole") {
|
||||
toggle("systemRole", (u) => ROLE_ORDER[u.systemRole] ?? 99);
|
||||
} else {
|
||||
toggle(field as keyof UserRow);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedUser = editState ? allUsers.find((u) => u.id === editState.userId) : null;
|
||||
|
||||
const isPending =
|
||||
updateRoleMutation.isPending ||
|
||||
setPermissionsMutation.isPending ||
|
||||
resetPermissionsMutation.isPending;
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
setRoleFilter("");
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">User Management</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage user roles and permission overrides
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value as SystemRole | "")}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
{Object.values(SystemRole).map((role) => (
|
||||
<option key={role} value={role}>{SYSTEM_ROLE_LABELS[role]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{actionError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionError(null)}
|
||||
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Table */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<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="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" />
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} 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">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sorted.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.name ?? <span className="italic text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{user.email}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
ROLE_BADGE_COLORS[user.systemRole as SystemRole] ?? ROLE_BADGE_COLORS[SystemRole.USER]
|
||||
}`}
|
||||
>
|
||||
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(user)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editState && selectedUser && (
|
||||
<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-2xl mx-4 flex flex-col max-h-[90vh]">
|
||||
{/* Modal Header */}
|
||||
<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">
|
||||
Edit User
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedUser.name ?? selectedUser.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEdit}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<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>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={editState.systemRole}
|
||||
onChange={(e) =>
|
||||
setEditState({ ...editState, systemRole: e.target.value as SystemRole })
|
||||
}
|
||||
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
>
|
||||
{Object.values(SystemRole).map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{SYSTEM_ROLE_LABELS[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveRole}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{updateRoleMutation.isPending ? "Saving…" : "Save Role"}
|
||||
</button>
|
||||
</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"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
</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>
|
||||
<input
|
||||
type="text"
|
||||
value={editState.chapterIds}
|
||||
onChange={(e) => setEditState({ ...editState, chapterIds: e.target.value })}
|
||||
placeholder="e.g. chapter-1, chapter-2"
|
||||
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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-200 dark:border-red-700 hover:border-red-300 dark:hover:border-red-600 rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{resetPermissionsMutation.isPending ? "Resetting…" : "Reset to Defaults"}
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEdit}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePermissions}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{setPermissionsMutation.isPending ? "Saving…" : "Save Permissions"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type CategoryRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type EditingCategory = {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const emptyCategory: EditingCategory = {
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
sortOrder: 0,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
export function UtilizationCategoriesClient() {
|
||||
const [editing, setEditing] = useState<EditingCategory | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { data: categories, isLoading } = trpc.utilizationCategory.list.useQuery();
|
||||
|
||||
const createMut = trpc.utilizationCategory.create.useMutation({
|
||||
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
const updateMut = trpc.utilizationCategory.update.useMutation({
|
||||
onSuccess: () => { void utils.utilizationCategory.list.invalidate(); setEditing(null); },
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
const maxOrder = Math.max(0, ...(categories ?? []).map((c) => (c as unknown as CategoryRow).sortOrder));
|
||||
setEditing({ ...emptyCategory, sortOrder: maxOrder + 1 });
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function openEdit(c: CategoryRow) {
|
||||
setEditing({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
description: c.description ?? "",
|
||||
sortOrder: c.sortOrder,
|
||||
isDefault: c.isDefault,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!editing) return;
|
||||
setError(null);
|
||||
|
||||
if (editing.id) {
|
||||
updateMut.mutate({
|
||||
id: editing.id,
|
||||
data: {
|
||||
code: editing.code,
|
||||
name: editing.name,
|
||||
description: editing.description || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
isDefault: editing.isDefault,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
code: editing.code,
|
||||
name: editing.name,
|
||||
description: editing.description || undefined,
|
||||
sortOrder: editing.sortOrder,
|
||||
isDefault: editing.isDefault,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMut.isPending || updateMut.isPending;
|
||||
const rows = (categories ?? []) as unknown as CategoryRow[];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Utilization Categories</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Categories assigned to projects for chargeability reporting
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<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-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">Loading...</td></tr>
|
||||
)}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">No categories yet.</td></tr>
|
||||
)}
|
||||
{rows.map((c) => (
|
||||
<tr key={c.id} className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 font-mono font-medium text-gray-900 dark:text-gray-100">{c.code}</td>
|
||||
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{c.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs max-w-xs truncate">{c.description ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{c.isDefault && (
|
||||
<span className="inline-flex items-center 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">Default</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-gray-400">{c.sortOrder}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button type="button" onClick={() => openEdit(c)} className="text-xs text-brand-600 hover:text-brand-800 font-medium">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{editing && (
|
||||
<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-md mx-4">
|
||||
<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">
|
||||
{editing.id ? "Edit Category" : "Add Category"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.code}
|
||||
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
|
||||
placeholder="Chg"
|
||||
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 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editing.sortOrder}
|
||||
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editing.name}
|
||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||
placeholder="Chargeable"
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Description</label>
|
||||
<textarea
|
||||
value={editing.description}
|
||||
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Revenue-generating client project work"
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.isDefault}
|
||||
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
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditing(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={handleSave}
|
||||
disabled={isPending || !editing.code || !editing.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
|
||||
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";
|
||||
|
||||
const ALLOCATION_STATUSES = Object.values(AllocationStatus);
|
||||
type EntryKind = "demand" | "assignment";
|
||||
|
||||
interface AllocationModalProps {
|
||||
allocation?: AllocationWithDetails | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
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] ?? "";
|
||||
}
|
||||
|
||||
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
|
||||
const isEditing = Boolean(allocation);
|
||||
const initialEntryKind: EntryKind = allocation && !allocation.resourceId ? "demand" : "assignment";
|
||||
const [entryKind, setEntryKind] = useState<EntryKind>(initialEntryKind);
|
||||
const isDemandEntry = entryKind === "demand";
|
||||
|
||||
const [resourceId, setResourceId] = useState(allocation?.resourceId ?? "");
|
||||
const [projectId, setProjectId] = useState(allocation?.projectId ?? "");
|
||||
const [roleId, setRoleId] = useState(allocation?.roleId ?? "");
|
||||
const [roleFreeText, setRoleFreeText] = useState(allocation?.role ?? "");
|
||||
const [headcount, setHeadcount] = useState(allocation?.headcount ?? 1);
|
||||
const [startDate, setStartDate] = useState(toDateInputValue(allocation?.startDate));
|
||||
const [endDate, setEndDate] = useState(toDateInputValue(allocation?.endDate));
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation?.hoursPerDay ?? 8);
|
||||
const [status, setStatus] = useState<AllocationStatus>(
|
||||
allocation?.status ?? AllocationStatus.PROPOSED,
|
||||
);
|
||||
const existingMeta = allocation?.metadata as Record<string, unknown> | undefined;
|
||||
const [isRecurring, setIsRecurring] = useState<boolean>(!!existingMeta?.recurrence);
|
||||
const [recurrence, setRecurrence] = useState<RecurrencePattern | undefined>(
|
||||
existingMeta?.recurrence as RecurrencePattern | undefined,
|
||||
);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: projects } = trpc.project.list.useQuery(
|
||||
{ limit: 500 },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const { data: rolesData } = trpc.role.list.useQuery(
|
||||
{ isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidatePlanningViews = () => {
|
||||
void utils.allocation.list.invalidate();
|
||||
void (utils as { allocation: { listView: { invalidate: () => Promise<unknown> } } }).allocation.listView.invalidate();
|
||||
void utils.allocation.listDemands.invalidate();
|
||||
void utils.allocation.listAssignments.invalidate();
|
||||
void utils.timeline.getEntries.invalidate();
|
||||
void utils.timeline.getEntriesView.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createAssignmentMutation = (trpc.allocation.createAssignment.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const updateMutation = (trpc.allocation.update.useMutation as any)({
|
||||
onSuccess: () => {
|
||||
invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
setServerError(err.message);
|
||||
},
|
||||
}) as {
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string };
|
||||
mutate: (input: unknown) => void;
|
||||
};
|
||||
|
||||
const isPending =
|
||||
createDemandMutation.isPending ||
|
||||
createAssignmentMutation.isPending ||
|
||||
updateMutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
setServerError(null);
|
||||
}, [resourceId, projectId, roleId, roleFreeText, startDate, endDate, hoursPerDay, status, entryKind]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
|
||||
if (!projectId) {
|
||||
setServerError("Please select a project.");
|
||||
return;
|
||||
}
|
||||
if (!isDemandEntry && !resourceId) {
|
||||
setServerError("Please select a resource.");
|
||||
return;
|
||||
}
|
||||
if (!startDate || !endDate) {
|
||||
setServerError("Please fill in start and end dates.");
|
||||
return;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (end < start) {
|
||||
setServerError("End date must be on or after start date.");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseMeta = (allocation?.metadata as Record<string, unknown> | undefined) ?? {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...baseMeta,
|
||||
...(isRecurring && recurrence ? { recurrence } : { recurrence: undefined }),
|
||||
};
|
||||
if (!isRecurring) delete metadata.recurrence;
|
||||
|
||||
// Determine role string from roleId if set
|
||||
const rolesList = rolesData ?? [];
|
||||
const selectedRole = rolesList.find((r) => r.id === roleId);
|
||||
const roleString = selectedRole ? selectedRole.name : (roleFreeText || undefined);
|
||||
|
||||
const percentage = Math.min(100, Math.round((hoursPerDay / 8) * 100));
|
||||
|
||||
if (isEditing && allocation) {
|
||||
updateMutation.mutate({
|
||||
id: getPlanningEntryMutationId(allocation),
|
||||
data: {
|
||||
resourceId: isDemandEntry ? undefined : (resourceId || undefined),
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount: isDemandEntry ? headcount : 1,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
} else if (isDemandEntry) {
|
||||
createDemandMutation.mutate({
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
headcount,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
});
|
||||
} else {
|
||||
createAssignmentMutation.mutate({
|
||||
resourceId,
|
||||
projectId,
|
||||
role: roleString,
|
||||
roleId: roleId || undefined,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
hoursPerDay,
|
||||
percentage,
|
||||
status: status as AllocationStatus,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||
|
||||
const resourceList = (resources?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>;
|
||||
const projectList = (projects?.projects ?? []) as Array<{ id: string; name: string; shortCode: string }>;
|
||||
const rolesList = (rolesData ?? []) as Array<{ id: string; name: string; color: string | null }>;
|
||||
const entryLabel = isDemandEntry ? "Open Demand" : "Assignment";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-xl mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* 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">
|
||||
{isEditing ? `Edit ${entryLabel}` : `New ${entryLabel}`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
{/* Demand toggle */}
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDemandEntry}
|
||||
disabled={isEditing}
|
||||
onChange={(e) => {
|
||||
setEntryKind(e.target.checked ? "demand" : "assignment");
|
||||
if (e.target.checked) setResourceId("");
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-100">Open demand</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
{isEditing
|
||||
? "Demand vs assignment type is fixed after creation during the compatibility migration."
|
||||
: "No resource assigned yet and tracked as staffing demand"}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
{isDemandEntry && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Headcount:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={headcount}
|
||||
onChange={(e) => setHeadcount(Math.max(1, Number(e.target.value)))}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-16 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm text-center dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resource is only required for assignments */}
|
||||
{!isDemandEntry && (
|
||||
<div>
|
||||
<label htmlFor="modal-resource" className={labelClass}>
|
||||
Resource <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-resource"
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className={inputClass}
|
||||
required={!isDemandEntry}
|
||||
>
|
||||
<option value="">Select a resource…</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project */}
|
||||
<div>
|
||||
<label htmlFor="modal-project" className={labelClass}>
|
||||
Project <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="modal-project"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
className={inputClass}
|
||||
required
|
||||
>
|
||||
<option value="">Select a project…</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode} — {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label htmlFor="modal-role" className={labelClass}>Role</label>
|
||||
<select
|
||||
id="modal-role"
|
||||
value={roleId}
|
||||
onChange={(e) => setRoleId(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">No role / custom…</option>
|
||||
{rolesList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!roleId && (
|
||||
<input
|
||||
type="text"
|
||||
value={roleFreeText}
|
||||
onChange={(e) => setRoleFreeText(e.target.value)}
|
||||
placeholder="Or type a custom role…"
|
||||
className={`${inputClass} mt-1`}
|
||||
maxLength={200}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-start" className={labelClass}>
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-end" className={labelClass}>
|
||||
End Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<DateInput
|
||||
id="modal-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate}
|
||||
className={inputClass}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours/Day + Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="modal-hours" className={labelClass}>
|
||||
Hours / Day
|
||||
</label>
|
||||
<input
|
||||
id="modal-hours"
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={0.5}
|
||||
max={8}
|
||||
step={0.5}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="modal-status" className={labelClass}>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="modal-status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as AllocationStatus)}
|
||||
className={inputClass}
|
||||
>
|
||||
{ALLOCATION_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring toggle */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => {
|
||||
setIsRecurring(e.target.checked);
|
||||
if (!e.target.checked) setRecurrence(undefined);
|
||||
}}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">Recurring schedule</span>
|
||||
</label>
|
||||
{isRecurring && (
|
||||
<div className="mt-2">
|
||||
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server error */}
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { AllocationModal } from "./AllocationModal.js";
|
||||
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, ColumnDef } from "@planarchy/shared";
|
||||
import { AllocationStatus, ALLOCATION_COLUMNS } from "@planarchy/shared";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
|
||||
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
|
||||
};
|
||||
|
||||
const ALL_ALLOC_STATUSES = [
|
||||
{ value: "PROPOSED", label: "Proposed" },
|
||||
{ value: "CONFIRMED", label: "Confirmed" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
type AllocationAssignmentsView = AllocationReadModel<AllocationLike>;
|
||||
type DemandRow = AllocationWithDetails & {
|
||||
sourceAllocationId?: string;
|
||||
requestedHeadcount?: number;
|
||||
unfilledHeadcount?: number;
|
||||
};
|
||||
|
||||
export function AllocationsClient() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAllocation, setEditingAllocation] = useState<AllocationWithDetails | null>(null);
|
||||
const [filterProjectId, setFilterProjectId] = useState<string>("");
|
||||
const [filterResourceId, setFilterResourceId] = useState<string>("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("");
|
||||
const [hidePastProjects, setHidePastProjects] = useState(true);
|
||||
const [hideCompletedProjects, setHideCompletedProjects] = useState(true);
|
||||
const [hideDraftProjects, setHideDraftProjects] = useState(false);
|
||||
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 selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts } = usePermissions();
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
const baseColumns = useMemo<ColumnDef[]>(
|
||||
() => (canViewCosts ? ALLOCATION_COLUMNS : ALLOCATION_COLUMNS.filter((c) => c.key !== "cost")),
|
||||
[canViewCosts],
|
||||
);
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig("allocations", baseColumns);
|
||||
const defaultKeys = useMemo(() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key), [baseColumns]);
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{
|
||||
projectId: filterProjectId || undefined,
|
||||
resourceId: filterResourceId || undefined,
|
||||
status: (filterStatus as AllocationStatus) || undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev, staleTime: 15_000 },
|
||||
) as { data: AllocationAssignmentsView | undefined; isLoading: boolean };
|
||||
|
||||
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.allocation.list.invalidate();
|
||||
await utils.allocation.listView.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filterProjectId, filterResourceId, filterStatus, hidePastProjects, hideCompletedProjects, hideDraftProjects]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingAllocation(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(alloc: AllocationWithDetails) {
|
||||
setEditingAllocation(alloc);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setEditingAllocation(null);
|
||||
}
|
||||
|
||||
const assignmentList = (allocationView?.assignments ?? []) as unknown as AllocationWithDetails[];
|
||||
const demandList = (allocationView?.demands ?? []) as unknown as DemandRow[];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const filteredAllocations = assignmentList.filter((alloc) => {
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredDemands = demandList.filter((alloc) => {
|
||||
if (filterResourceId) return false;
|
||||
if (hidePastProjects && alloc.project?.endDate && new Date(alloc.project.endDate) < today) return false;
|
||||
if (hideCompletedProjects && alloc.project?.status && ["COMPLETED", "CANCELLED"].includes(alloc.project.status)) return false;
|
||||
if (hideDraftProjects && alloc.project?.status === "DRAFT") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const allocViewPrefs = useViewPrefs("allocations");
|
||||
const { sorted, sortField, sortDir, toggle } = useTableSort(filteredAllocations, {
|
||||
initialField: allocViewPrefs.savedSort?.field ?? null,
|
||||
initialDir: allocViewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
allocViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const allocationIds = sorted.map((a) => a.id);
|
||||
const allocationMutationIdsByDisplayId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
sorted.map((allocation) => [allocation.id, getPlanningEntryMutationId(allocation)]),
|
||||
),
|
||||
[sorted],
|
||||
);
|
||||
const selectedMutationIds = useMemo(
|
||||
() =>
|
||||
selection.selectedArray.flatMap((displayId) => {
|
||||
const mutationId = allocationMutationIdsByDisplayId.get(displayId);
|
||||
return mutationId ? [mutationId] : [];
|
||||
}),
|
||||
[allocationMutationIdsByDisplayId, selection.selectedArray],
|
||||
);
|
||||
|
||||
function handleSort(field: string) {
|
||||
if (field === "resource") {
|
||||
toggle("resource", (a) => a.resource?.displayName ?? null);
|
||||
} else if (field === "project") {
|
||||
toggle("project", (a) => a.project?.name ?? null);
|
||||
} else {
|
||||
toggle(field);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setFilterProjectId("");
|
||||
setFilterResourceId("");
|
||||
setFilterStatus("");
|
||||
setHidePastProjects(false);
|
||||
setHideCompletedProjects(false);
|
||||
setHideDraftProjects(false);
|
||||
}
|
||||
|
||||
const chips = [
|
||||
...(filterProjectId ? [{ label: `Project filter active`, onRemove: () => setFilterProjectId("") }] : []),
|
||||
...(filterResourceId ? [{ label: `Resource filter active`, onRemove: () => setFilterResourceId("") }] : []),
|
||||
...(filterStatus ? [{ label: `Status: ${filterStatus}`, onRemove: () => setFilterStatus("") }] : []),
|
||||
...(hidePastProjects ? [{ label: "Hiding past projects", onRemove: () => setHidePastProjects(false) }] : []),
|
||||
...(hideCompletedProjects ? [{ label: "Hiding completed/cancelled", onRemove: () => setHideCompletedProjects(false) }] : []),
|
||||
...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []),
|
||||
];
|
||||
|
||||
function formatPeriod(alloc: AllocationWithDetails) {
|
||||
return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate);
|
||||
}
|
||||
|
||||
function handleSingleDelete(allocation: AllocationWithDetails) {
|
||||
const id = getPlanningEntryMutationId(allocation);
|
||||
|
||||
if (!allocation.resourceId) {
|
||||
deleteDemandMutation.mutate({ id });
|
||||
return;
|
||||
}
|
||||
|
||||
deleteAssignmentMutation.mutate({ id });
|
||||
}
|
||||
|
||||
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
{isLoading
|
||||
? "Loading…"
|
||||
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/api/reports/allocations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
↓ PDF
|
||||
</a>
|
||||
<a
|
||||
href="/api/reports/allocations?format=xlsx"
|
||||
download
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
↓ XLS
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
New Planning Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<ProjectCombobox
|
||||
value={filterProjectId || null}
|
||||
onChange={(id) => setFilterProjectId(id ?? "")}
|
||||
placeholder="Filter by project…"
|
||||
className="min-w-[280px]"
|
||||
/>
|
||||
|
||||
<ResourceCombobox
|
||||
value={filterResourceId || null}
|
||||
onChange={(id) => setFilterResourceId(id ?? "")}
|
||||
placeholder="Filter by resource…"
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePastProjects}
|
||||
onChange={(e) => setHidePastProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide past
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideCompletedProjects}
|
||||
onChange={(e) => setHideCompletedProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide completed
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDraftProjects}
|
||||
onChange={(e) => setHideDraftProjects(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Hide drafts
|
||||
</label>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
{/* Filter chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(allocationIds)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.isIndeterminate(allocationIds);
|
||||
}}
|
||||
onChange={() => selection.toggleAll(allocationIds)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
const tooltips: Record<string, { tip: string; width?: string }> = {
|
||||
role: { tip: "The role this allocation was created for. May differ from the resource's primary role." },
|
||||
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." },
|
||||
status: { tip: "PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed.", width: "w-72" },
|
||||
};
|
||||
const t = tooltips[col.key];
|
||||
const fieldMap: Record<string, string> = { dates: "startDate", hoursPerDay: "hoursPerDay", cost: "dailyCostCents" };
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
field={fieldMap[col.key] ?? col.key}
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
{...(t?.tip ? { tooltip: t.tip } : {})}
|
||||
{...(t?.width ? { tooltipWidth: t.width } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-gray-400 dark:text-gray-500 text-sm">Loading allocations…</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments found.</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
sorted.map((alloc) => {
|
||||
const isSelected = selection.selectedIds.has(alloc.id);
|
||||
return (
|
||||
<tr key={alloc.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.toggle(alloc.id)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
switch (col.key) {
|
||||
case "resource":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
|
||||
case "project":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{alloc.project ? (
|
||||
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
||||
) : "—"}
|
||||
</td>
|
||||
);
|
||||
case "role":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{alloc.role}</td>;
|
||||
case "dates":
|
||||
return <td key={col.key} className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatPeriod(alloc)}</td>;
|
||||
case "hoursPerDay":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
|
||||
case "cost":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{(alloc.dailyCostCents / 100).toFixed(0)} €</td>;
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[alloc.status] ?? "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"}`}>
|
||||
{alloc.status}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
||||
}
|
||||
})}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: alloc })}
|
||||
disabled={singleDeletePending}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isLoading && filteredDemands.length > 0 && (
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-amber-200 dark:border-amber-800/60 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-800/60 bg-amber-50/70 dark:bg-amber-950/20 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
||||
Placeholder demand rows not yet assigned to a resource.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
{filteredDemands.length} item{filteredDemands.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-amber-100 dark:divide-amber-900/40">
|
||||
{filteredDemands.map((demand) => (
|
||||
<div
|
||||
key={demand.id}
|
||||
className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{demand.project ? (
|
||||
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
|
||||
) : "Unknown project"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
|
||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||
{demand.unfilledHeadcount ?? demand.headcount} / {demand.requestedHeadcount ?? demand.headcount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(demand as AllocationWithDetails)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
|
||||
disabled={singleDeletePending}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
|
||||
setBatchStatusPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm single delete */}
|
||||
{confirmDelete?.single && (
|
||||
<ConfirmDialog
|
||||
title="Delete Allocation"
|
||||
message={`Delete allocation for ${confirmDelete.single.resource?.displayName ?? "resource"} on ${confirmDelete.single.project?.name ?? "project"}?`}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
handleSingleDelete(confirmDelete.single!);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm batch delete */}
|
||||
{confirmDelete?.ids && (
|
||||
<ConfirmDialog
|
||||
title="Delete Allocations"
|
||||
message={`Delete ${confirmDelete.ids.length} selected allocation${confirmDelete.ids.length !== 1 ? "s" : ""}? This cannot be undone.`}
|
||||
confirmLabel="Delete All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
batchDeleteMutation.mutate({ ids: confirmDelete.ids! });
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm batch status */}
|
||||
{confirmBatchStatus && (
|
||||
<ConfirmDialog
|
||||
title="Update Allocation Status"
|
||||
message={`Set ${confirmBatchStatus.ids.length} allocation${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
batchStatusMutation.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchStatus(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{
|
||||
label: "Set Status…",
|
||||
onClick: () => setBatchStatusPicker(true),
|
||||
disabled: batchStatusMutation.isPending,
|
||||
},
|
||||
{
|
||||
label: `Delete (${selection.count})`,
|
||||
variant: "danger",
|
||||
onClick: () => setConfirmDelete({ ids: selectedMutationIds }),
|
||||
disabled: batchDeleteMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<AllocationModal allocation={editingAllocation} onClose={closeModal} onSuccess={closeModal} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface OpenDemandAllocation {
|
||||
id: string;
|
||||
entityId?: string | null;
|
||||
sourceAllocationId?: string | null;
|
||||
projectId: string;
|
||||
roleId: string | null;
|
||||
role: string | null;
|
||||
headcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
roleEntity?: { id: string; name: string; color: string | null } | null;
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
interface FillOpenDemandModalProps {
|
||||
allocation: OpenDemandAllocation;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("en-GB", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpenDemandModalProps) {
|
||||
const [resourceId, setResourceId] = useState("");
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number>(allocation.hoursPerDay);
|
||||
const [search, setSearch] = useState("");
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const invalidatePlanningViews = 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();
|
||||
};
|
||||
|
||||
const { data: resources } = trpc.resource.list.useQuery(
|
||||
{ isActive: true, search: search || undefined, limit: 100 },
|
||||
{ staleTime: 15_000 },
|
||||
) as { data: { resources: Array<{ id: string; displayName: string; eid: string }> } | undefined };
|
||||
|
||||
const fillOpenDemandMutation = trpc.allocation.fillOpenDemandByAllocation.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidatePlanningViews();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => setServerError(err.message),
|
||||
});
|
||||
|
||||
const roleName = allocation.roleEntity?.name ?? allocation.role ?? "Unknown Role";
|
||||
const roleColor = allocation.roleEntity?.color ?? "#6366f1";
|
||||
|
||||
const resourceList = resources?.resources ?? [];
|
||||
const isPending = fillOpenDemandMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!resourceId) {
|
||||
setServerError("Please select a resource.");
|
||||
return;
|
||||
}
|
||||
fillOpenDemandMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(allocation),
|
||||
resourceId,
|
||||
hoursPerDay,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<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">Assign Open Demand</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
{/* Demand summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-4 flex items-start gap-3">
|
||||
<div className="w-3 h-3 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: roleColor }} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{roleName}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{allocation.project?.name} · {formatDate(allocation.startDate)} – {formatDate(allocation.endDate)}
|
||||
</div>
|
||||
{allocation.headcount > 1 && (
|
||||
<div className="text-xs text-amber-600 mt-0.5">
|
||||
{allocation.headcount} slots remaining — assigning one resource
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-5 space-y-4">
|
||||
{/* Resource search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search Resource
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or EID…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Assign Resource <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={resourceId}
|
||||
onChange={(e) => setResourceId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
size={Math.min(6, Math.max(3, resourceList.length))}
|
||||
>
|
||||
<option value="">Select a resource…</option>
|
||||
{resourceList.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.displayName} ({r.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hours / Day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value))}
|
||||
min={0.5}
|
||||
max={8}
|
||||
step={0.5}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={isPending || !resourceId} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
|
||||
{isPending ? "Assigning…" : "Create Assignment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { RecurrenceFrequency } from "@planarchy/shared";
|
||||
import type { RecurrencePattern } from "@planarchy/shared";
|
||||
|
||||
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
interface RecurrenceEditorProps {
|
||||
value: RecurrencePattern | undefined;
|
||||
onChange: (pattern: RecurrencePattern | undefined) => void;
|
||||
}
|
||||
|
||||
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
const freq = value?.frequency ?? RecurrenceFrequency.WEEKLY;
|
||||
|
||||
function update(patch: Partial<RecurrencePattern>) {
|
||||
onChange({ ...value, frequency: freq, ...patch });
|
||||
}
|
||||
|
||||
function setFrequency(f: RecurrenceFrequency) {
|
||||
// Reset pattern-specific fields when switching frequency
|
||||
onChange({ frequency: f });
|
||||
}
|
||||
|
||||
function toggleWeekday(dow: number) {
|
||||
const current = value?.weekdays ?? [];
|
||||
const next = current.includes(dow)
|
||||
? current.filter((d) => d !== dow)
|
||||
: [...current, dow].sort((a, b) => a - b);
|
||||
update({ weekdays: next });
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100";
|
||||
const labelClass = "text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1";
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{Object.values(RecurrenceFrequency).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFrequency(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
|
||||
freq === f
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
|
||||
}`}
|
||||
>
|
||||
{f === RecurrenceFrequency.WEEKLY
|
||||
? "Weekly"
|
||||
: f === RecurrenceFrequency.BIWEEKLY
|
||||
? "Biweekly"
|
||||
: f === RecurrenceFrequency.MONTHLY
|
||||
? "Monthly"
|
||||
: "Custom"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday picker — WEEKLY and BIWEEKLY */}
|
||||
{(freq === RecurrenceFrequency.WEEKLY || freq === RecurrenceFrequency.BIWEEKLY) && (
|
||||
<div>
|
||||
<span className={labelClass}>Days of week</span>
|
||||
<div className="flex gap-1">
|
||||
{WEEKDAY_LABELS.map((label, dow) => {
|
||||
const selected = (value?.weekdays ?? []).includes(dow);
|
||||
return (
|
||||
<button
|
||||
key={dow}
|
||||
type="button"
|
||||
onClick={() => toggleWeekday(dow)}
|
||||
className={`w-9 h-9 text-xs rounded-full border font-medium transition-colors ${
|
||||
selected
|
||||
? "bg-brand-600 text-white border-brand-600"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-brand-400"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Biweekly interval */}
|
||||
{freq === RecurrenceFrequency.BIWEEKLY && (
|
||||
<div>
|
||||
<label className={labelClass}>Every N weeks</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={8}
|
||||
value={value?.interval ?? 2}
|
||||
onChange={(e) => update({ interval: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly — day of month */}
|
||||
{freq === RecurrenceFrequency.MONTHLY && (
|
||||
<div>
|
||||
<label className={labelClass}>Day of month (1–31)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={value?.monthDay ?? 1}
|
||||
onChange={(e) => update({ monthDay: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom — hoursPerDay override */}
|
||||
{freq === RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per active day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={value?.hoursPerDay ?? 8}
|
||||
onChange={(e) => update({ hoursPerDay: Number(e.target.value) })}
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional hours override for WEEKLY/BIWEEKLY/MONTHLY */}
|
||||
{freq !== RecurrenceFrequency.CUSTOM && (
|
||||
<div>
|
||||
<label className={labelClass}>Hours per recurring day (optional override)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={24}
|
||||
step={0.5}
|
||||
placeholder="Use allocation default"
|
||||
value={value?.hoursPerDay ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value === "") {
|
||||
delete next.hoursPerDay;
|
||||
} else {
|
||||
next.hoursPerDay = Number(e.target.value);
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={`${inputClass} w-40`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional date range overrides */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Recurrence start (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value?.startDate ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value) {
|
||||
next.startDate = e.target.value;
|
||||
} else {
|
||||
delete next.startDate;
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Recurrence end (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value?.endDate ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = { ...value, frequency: freq } as RecurrencePattern;
|
||||
if (e.target.value) {
|
||||
next.endDate = e.target.value;
|
||||
} else {
|
||||
delete next.endDate;
|
||||
}
|
||||
onChange(next);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
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"];
|
||||
|
||||
// SVG fill colors for the bar chart (work in both light and dark contexts)
|
||||
const PROFICIENCY_SVG_COLORS = ["#9ca3af", "#60a5fa", "#818cf8", "#f59e0b", "#4ade80"];
|
||||
|
||||
// Tailwind class sets per proficiency level (1–5), dark-mode aware
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillRule = { skill: string; minProficiency: number };
|
||||
|
||||
export function SkillsAnalytics() {
|
||||
const datalistId = useId();
|
||||
|
||||
// ── Skill table filters ──────────────────────────────────────────────────
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [minCount, setMinCount] = useState<number>(1);
|
||||
const [skillSearch, setSkillSearch] = useState<string>("");
|
||||
|
||||
// ── People Finder ────────────────────────────────────────────────────────
|
||||
const [rules, setRules] = useState<SkillRule[]>([]);
|
||||
const [operator, setOperator] = useState<"AND" | "OR">("AND");
|
||||
const [peopleChapter, setPeopleChapter] = useState<string>("");
|
||||
|
||||
const { data, isLoading, error } = trpc.resource.getSkillsAnalytics.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const activeRules = rules.filter((r) => r.skill.trim().length > 0);
|
||||
const { data: peopleResults, isFetching: peopleFetching } =
|
||||
trpc.resource.searchBySkills.useQuery(
|
||||
{
|
||||
rules: activeRules,
|
||||
operator,
|
||||
...(peopleChapter ? { chapter: peopleChapter } : {}),
|
||||
},
|
||||
{
|
||||
enabled: activeRules.length > 0,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
function addRule() {
|
||||
setRules((prev) => [...prev, { skill: "", minProficiency: 1 }]);
|
||||
}
|
||||
|
||||
function removeRule(idx: number) {
|
||||
setRules((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateRule(idx: number, patch: Partial<SkillRule>) {
|
||||
setRules((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function exportXlsx() {
|
||||
if (!data) return;
|
||||
const rows = data.aggregated.map((e) => ({
|
||||
Skill: e.skill,
|
||||
Category: e.category,
|
||||
"# Resources": e.count,
|
||||
"Avg Proficiency": e.avgProficiency,
|
||||
Chapters: e.chapters.join(", "),
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Skills");
|
||||
XLSX.writeFile(wb, `skills-analytics-${Date.now()}.xlsx`);
|
||||
}
|
||||
|
||||
const allSkillNames = (data?.aggregated ?? []).map((e) => e.skill);
|
||||
|
||||
const filtered = (data?.aggregated ?? []).filter((e) => {
|
||||
if (categoryFilter && e.category !== categoryFilter) return false;
|
||||
if (e.count < minCount) return false;
|
||||
if (skillSearch && !e.skill.toLowerCase().includes(skillSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const { sorted: sortedSkills, sortField: skillSortField, sortDir: skillSortDir, toggle: skillToggle } = useTableSort(filtered);
|
||||
const top20 = filtered.slice(0, 20);
|
||||
const gapSkills = (data?.aggregated ?? []).filter((e) => e.count < 3 && e.avgProficiency >= 3);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Skills Analytics</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{data?.totalResources} active resources · {data?.totalSkillEntries} distinct skills
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportXlsx}
|
||||
disabled={!data}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
↓ Export XLS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── People Finder ──────────────────────────────────────────────────── */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">People Finder</h2>
|
||||
<span className="text-xs text-gray-400">
|
||||
Find resources that match skill criteria
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rules */}
|
||||
<datalist id={datalistId}>
|
||||
{allSkillNames.map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 flex-wrap">
|
||||
{/* AND / OR connector label */}
|
||||
{idx > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator((op) => (op === "AND" ? "OR" : "AND"))}
|
||||
className="w-12 text-center text-xs font-bold px-2 py-1.5 rounded-lg border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 transition-colors shrink-0"
|
||||
>
|
||||
{operator}
|
||||
</button>
|
||||
)}
|
||||
{idx === 0 && (
|
||||
<span className="w-12 text-center text-xs font-medium text-gray-400 shrink-0">
|
||||
knows
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skill input */}
|
||||
<input
|
||||
type="text"
|
||||
list={datalistId}
|
||||
placeholder="Skill name…"
|
||||
value={rule.skill}
|
||||
onChange={(e) => updateRule(idx, { skill: e.target.value })}
|
||||
className="flex-1 min-w-40 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
|
||||
{/* Min proficiency selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-400 shrink-0">min.</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200">
|
||||
{[1, 2, 3, 4, 5].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
title={PROFICIENCY_LABELS[lvl]}
|
||||
onClick={() => updateRule(idx, { minProficiency: lvl })}
|
||||
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||
rule.minProficiency === lvl
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRule(idx)}
|
||||
className="text-gray-400 hover:text-red-500 transition-colors p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add rule + chapter filter row */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-dashed border-brand-300 text-brand-600 hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add skill rule
|
||||
</button>
|
||||
|
||||
{rules.length > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500">Match:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("AND")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-l-lg border transition-colors ${
|
||||
operator === "AND"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
All (AND)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperator("OR")}
|
||||
className={`px-2.5 py-1 text-xs font-medium rounded-r-lg border -ml-px transition-colors ${
|
||||
operator === "OR"
|
||||
? "bg-brand-600 border-brand-600 text-white"
|
||||
: "bg-white border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
Any (OR)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data?.allChapters ?? []).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<span className="text-xs text-gray-500">Chapter:</span>
|
||||
<select
|
||||
value={peopleChapter}
|
||||
onChange={(e) => setPeopleChapter(e.target.value)}
|
||||
className="px-2 py-1.5 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All chapters</option>
|
||||
{(data?.allChapters ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{activeRules.length > 0 && (
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
{peopleFetching ? (
|
||||
<div className="text-sm text-gray-400 animate-pulse">Searching…</div>
|
||||
) : peopleResults && peopleResults.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">No resources match these criteria.</p>
|
||||
) : peopleResults && peopleResults.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{peopleResults.length} resource{peopleResults.length !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{peopleResults.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-sm font-medium text-gray-900 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{person.displayName}
|
||||
</a>
|
||||
{person.chapter && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-100">
|
||||
{person.chapter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{person.matchedSkills.map((s) => (
|
||||
<span
|
||||
key={s.skill}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border ${proficiencyClasses(s.proficiency)}`}
|
||||
>
|
||||
{s.skill}
|
||||
<span className="font-semibold">{s.proficiency}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/resources/${person.id}`}
|
||||
className="text-xs text-brand-600 hover:underline shrink-0 mt-0.5"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Skill table filters ────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Fuzzy search */}
|
||||
<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 skills…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.target.value)}
|
||||
className="pl-8 pr-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
Min. resources:
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={minCount}
|
||||
onChange={(e) => setMinCount(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
||||
className="w-16 px-2 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="text-sm text-gray-400">{filtered.length} skills shown</span>
|
||||
|
||||
{(skillSearch || categoryFilter || minCount > 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSkillSearch(""); setCategoryFilter(""); setMinCount(1); }}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top 20 Skills Bar Chart */}
|
||||
{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>
|
||||
<p className="text-xs text-gray-400 mt-2">Bar color = average proficiency (light → dark = low → high)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Gap */}
|
||||
{gapSkills.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-3">
|
||||
Skills Gap
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">high proficiency, few practitioners (<3)</span>
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{gapSkills.map((e) => (
|
||||
<button
|
||||
key={e.skill}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 3 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
title="Add to People Finder"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
{e.skill}
|
||||
<span className="text-xs opacity-70">{e.count} person{e.count !== 1 ? "s" : ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Click a skill to add it to the People Finder above.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Skill" field="skill" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Category" field="category" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} />
|
||||
<SortableColumnHeader label="Resources" field="count" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<SortableColumnHeader label="Avg Prof." field="avgProficiency" sortField={skillSortField} sortDir={skillSortDir} onSort={skillToggle} align="right" />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chapters</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Find</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedSkills.map((e) => (
|
||||
<tr key={e.skill} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900">{e.skill}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500">{e.category}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700">{e.count}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<ProficiencyBadge value={e.avgProficiency} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{e.chapters.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Add to People Finder"
|
||||
onClick={() => {
|
||||
setRules((prev) => [...prev, { skill: e.skill, minProficiency: 1 }]);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
className="text-gray-400 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedSkills.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-400 text-sm">
|
||||
No skills found matching the filters.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition, FieldOption, StaffingRequirement } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { RolePresetsEditor } from "./RolePresetsEditor.js";
|
||||
|
||||
const FIELD_TYPES: { value: FieldType; label: string }[] = [
|
||||
{ value: FieldType.TEXT, label: "Text" },
|
||||
{ value: FieldType.TEXTAREA, label: "Textarea" },
|
||||
{ value: FieldType.NUMBER, label: "Number" },
|
||||
{ value: FieldType.BOOLEAN, label: "Boolean" },
|
||||
{ value: FieldType.DATE, label: "Date" },
|
||||
{ value: FieldType.SELECT, label: "Select" },
|
||||
{ value: FieldType.MULTI_SELECT, label: "Multi-Select" },
|
||||
{ value: FieldType.URL, label: "URL" },
|
||||
{ value: FieldType.EMAIL, label: "Email" },
|
||||
];
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
|
||||
|
||||
const BTN_DANGER =
|
||||
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
|
||||
|
||||
function makeEmptyField(order: number): BlueprintFieldDefinition {
|
||||
return {
|
||||
id: Math.random().toString(36).slice(2),
|
||||
key: "",
|
||||
label: "",
|
||||
type: FieldType.TEXT,
|
||||
required: false,
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OptionsEditor — for SELECT / MULTI_SELECT
|
||||
// ---------------------------------------------------------------------------
|
||||
interface OptionsEditorProps {
|
||||
options: FieldOption[];
|
||||
onChange: (options: FieldOption[]) => void;
|
||||
}
|
||||
|
||||
function OptionsEditor({ options, onChange }: OptionsEditorProps) {
|
||||
function addOption() {
|
||||
onChange([...options, { value: "", label: "" }]);
|
||||
}
|
||||
|
||||
function removeOption(idx: number) {
|
||||
onChange(options.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updateOption(idx: number, field: "value" | "label", val: string) {
|
||||
const next = options.map((o, i) =>
|
||||
i === idx ? { ...o, [field]: val } : o,
|
||||
);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-xs font-medium text-gray-600">Options</p>
|
||||
{options.map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={opt.value}
|
||||
onChange={(e) => updateOption(idx, "value", e.target.value)}
|
||||
placeholder="value"
|
||||
className={`${INPUT_CLS} flex-1`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={opt.label}
|
||||
onChange={(e) => updateOption(idx, "label", e.target.value)}
|
||||
placeholder="label"
|
||||
className={`${INPUT_CLS} flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(idx)}
|
||||
className={BTN_DANGER}
|
||||
aria-label="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
+ Add option
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FieldRow — a single field definition row
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FieldRowProps {
|
||||
field: BlueprintFieldDefinition;
|
||||
onChange: (field: BlueprintFieldDefinition) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function FieldRow({ field, onChange, onDelete }: FieldRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const needsOptions =
|
||||
field.type === FieldType.SELECT || field.type === FieldType.MULTI_SELECT;
|
||||
|
||||
function update<K extends keyof BlueprintFieldDefinition>(
|
||||
key: K,
|
||||
value: BlueprintFieldDefinition[K],
|
||||
) {
|
||||
onChange({ ...field, [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||
{/* Main row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Drag handle placeholder */}
|
||||
<span className="text-gray-300 cursor-grab select-none text-lg leading-none">
|
||||
☰
|
||||
</span>
|
||||
|
||||
{/* Key */}
|
||||
<input
|
||||
type="text"
|
||||
value={field.key}
|
||||
onChange={(e) => update("key", e.target.value)}
|
||||
placeholder="field_key"
|
||||
className={`${INPUT_CLS} w-36 font-mono`}
|
||||
aria-label="Field key"
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => update("label", e.target.value)}
|
||||
placeholder="Label"
|
||||
className={`${INPUT_CLS} w-40`}
|
||||
aria-label="Field label"
|
||||
/>
|
||||
|
||||
{/* Type */}
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(e) => {
|
||||
const t = e.target.value as FieldType;
|
||||
// Clear options when switching away from select types
|
||||
const clearedOptions =
|
||||
t === FieldType.SELECT || t === FieldType.MULTI_SELECT
|
||||
? field.options ?? []
|
||||
: undefined;
|
||||
onChange({ ...field, type: t, options: clearedOptions } as BlueprintFieldDefinition);
|
||||
}}
|
||||
className={`${INPUT_CLS} w-36`}
|
||||
aria-label="Field type"
|
||||
>
|
||||
{FIELD_TYPES.map((ft) => (
|
||||
<option key={ft.value} value={ft.value}>
|
||||
{ft.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Required */}
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => update("required", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Req.
|
||||
</label>
|
||||
|
||||
{/* Expand/Collapse optional fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 ml-auto whitespace-nowrap"
|
||||
aria-label={expanded ? "Collapse options" : "Expand options"}
|
||||
>
|
||||
{expanded ? "▲ less" : "▼ more"}
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className={BTN_DANGER}
|
||||
aria-label="Delete field"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded optional fields */}
|
||||
{expanded && (
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">Group</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.group ?? ""}
|
||||
onChange={(e) => update("group", e.target.value || undefined)}
|
||||
placeholder="Section heading"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Placeholder
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
update("placeholder", e.target.value || undefined)
|
||||
}
|
||||
placeholder="Placeholder text"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.description ?? ""}
|
||||
onChange={(e) =>
|
||||
update("description", e.target.value || undefined)
|
||||
}
|
||||
placeholder="Helper text"
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 col-span-full pt-1">
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.showInList ?? false}
|
||||
onChange={(e) => update("showInList", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Show in list view
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isFilterable ?? false}
|
||||
onChange={(e) => update("isFilterable", e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Filterable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{needsOptions && (
|
||||
<div className="col-span-full">
|
||||
<OptionsEditor
|
||||
options={field.options ?? []}
|
||||
onChange={(opts) => update("options", opts)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options inline hint when collapsed */}
|
||||
{!expanded && needsOptions && (field.options?.length ?? 0) === 0 && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
No options defined yet — click ▼ more to add them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlueprintFieldEditor — the modal
|
||||
// ---------------------------------------------------------------------------
|
||||
interface BlueprintFieldEditorProps {
|
||||
blueprintId: string;
|
||||
blueprintName: string;
|
||||
initialFieldDefs: BlueprintFieldDefinition[];
|
||||
initialRolePresets?: StaffingRequirement[];
|
||||
initialTab?: "fields" | "presets";
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BlueprintFieldEditor({
|
||||
blueprintId,
|
||||
blueprintName,
|
||||
initialFieldDefs,
|
||||
initialRolePresets = [],
|
||||
initialTab = "fields",
|
||||
onClose,
|
||||
}: BlueprintFieldEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"fields" | "presets">(initialTab);
|
||||
const [fields, setFields] = useState<BlueprintFieldDefinition[]>(
|
||||
() =>
|
||||
[...initialFieldDefs].sort((a, b) => a.order - b.order),
|
||||
);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [presetSaveError, setPresetSaveError] = useState<string | null>(null);
|
||||
|
||||
const updateMutation = trpc.blueprint.update.useMutation();
|
||||
|
||||
const presetMutation = trpc.blueprint.updateRolePresets.useMutation();
|
||||
|
||||
function addField() {
|
||||
setFields((prev) => [...prev, makeEmptyField(prev.length)]);
|
||||
}
|
||||
|
||||
function removeField(idx: number) {
|
||||
setFields((prev) =>
|
||||
prev
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((f, i) => ({ ...f, order: i })),
|
||||
);
|
||||
}
|
||||
|
||||
function updateField(idx: number, updated: BlueprintFieldDefinition) {
|
||||
setFields((prev) =>
|
||||
prev.map((f, i) => (i === idx ? updated : f)),
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null);
|
||||
// Reassign order by current list position
|
||||
const normalised = fields.map((f, i) => ({ ...f, order: i }));
|
||||
updateMutation.mutate(
|
||||
{
|
||||
id: blueprintId,
|
||||
data: { fieldDefs: normalised },
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.blueprint.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(err.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Edit Fields:{" "}
|
||||
<span className="text-gray-600 font-normal">{blueprintName}</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 px-6">
|
||||
{(["fields", "presets"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-brand-500 text-brand-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab === "fields" ? "Fields" : "Role Presets"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "fields" ? (
|
||||
<>
|
||||
{/* Field list */}
|
||||
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">
|
||||
No fields yet. Click “+ Add Field” to get started.
|
||||
</p>
|
||||
)}
|
||||
{fields.map((field, idx) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
onChange={(updated) => updateField(idx, updated)}
|
||||
onDelete={() => removeField(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add field button */}
|
||||
<div className="px-6 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{saveError && (
|
||||
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className={BTN_PRIMARY}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save Fields"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Role presets are auto-loaded in Step 3 of the Project Creation Wizard when this blueprint is selected.
|
||||
</p>
|
||||
<RolePresetsEditor
|
||||
initialPresets={initialRolePresets}
|
||||
onSave={(presets) =>
|
||||
presetMutation.mutate(
|
||||
{ id: blueprintId, rolePresets: presets },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await utils.blueprint.list.invalidate();
|
||||
setPresetSaveError(null);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setPresetSaveError(err.message);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
isSaving={presetMutation.isPending}
|
||||
saveError={presetSaveError}
|
||||
/>
|
||||
<div className="flex justify-start mt-4 border-t border-gray-200 pt-4">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FormEvent, MouseEvent } from "react";
|
||||
import { BlueprintTarget } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { BlueprintFieldEditor } from "./BlueprintFieldEditor.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_PRIMARY =
|
||||
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
|
||||
|
||||
const BTN_SECONDARY =
|
||||
"px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium";
|
||||
|
||||
interface NewBlueprintModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type BlueprintTargetValue = "RESOURCE" | "PROJECT";
|
||||
type BlueprintSortField = "name" | "target" | "fieldCount" | "presetCount" | "global";
|
||||
|
||||
function NewBlueprintModal({ onClose, onCreated }: NewBlueprintModalProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [target, setTarget] = useState<BlueprintTargetValue>("RESOURCE");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = trpc.blueprint.create.useMutation();
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim()) {
|
||||
setError("Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target: target as BlueprintTarget,
|
||||
fieldDefs: [],
|
||||
defaults: {},
|
||||
validationRules: [],
|
||||
});
|
||||
await utils.blueprint.list.invalidate();
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create blueprint.");
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent<HTMLDivElement>) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" onClick={handleBackdropClick}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">New Blueprint</h2>
|
||||
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Name <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Resource Extended Fields" className={INPUT_CLS} autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className={`${INPUT_CLS} resize-none`} rows={2} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-gray-700">Target</label>
|
||||
<select value={target} onChange={(e) => setTarget(e.target.value as BlueprintTargetValue)} className={INPUT_CLS}>
|
||||
<option value="RESOURCE">Resource</option>
|
||||
<option value="PROJECT">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</p>}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
|
||||
<button type="submit" disabled={createMutation.isPending} className={BTN_PRIMARY}>
|
||||
{createMutation.isPending ? "Creating…" : "Create Blueprint"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlueprintRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
target: BlueprintTargetValue;
|
||||
fieldDefs: unknown;
|
||||
rolePresets: unknown;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
interface BlueprintCardProps {
|
||||
blueprint: BlueprintRow;
|
||||
onEditFields: () => void;
|
||||
onEditStaffing: () => void;
|
||||
onToggleGlobal: () => void;
|
||||
onDelete: () => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
}
|
||||
|
||||
function BlueprintCard({
|
||||
blueprint,
|
||||
onEditFields,
|
||||
onEditStaffing,
|
||||
onToggleGlobal,
|
||||
onDelete,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: BlueprintCardProps) {
|
||||
const fieldDefs = Array.isArray(blueprint.fieldDefs) ? (blueprint.fieldDefs as BlueprintFieldDefinition[]) : [];
|
||||
const rolePresets = Array.isArray(blueprint.rolePresets) ? (blueprint.rolePresets as unknown[]) : [];
|
||||
const fieldCount = fieldDefs.length;
|
||||
const presetCount = rolePresets.length;
|
||||
const isProject = blueprint.target === "PROJECT";
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border p-5 flex flex-col gap-3 hover:shadow-sm transition-shadow ${isSelected ? "border-brand-400 bg-brand-50" : "border-gray-200"}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="mt-0.5 rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{blueprint.name}</h3>
|
||||
{blueprint.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{blueprint.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
|
||||
{blueprint.target}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
|
||||
<span>{fieldCount === 0 ? "No fields" : `${fieldCount} field${fieldCount === 1 ? "" : "s"}`}</span>
|
||||
{isProject && (
|
||||
<span className={presetCount > 0 ? "text-brand-600 font-medium" : ""}>
|
||||
{presetCount === 0 ? "No staffing presets" : `${presetCount} staffing preset${presetCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
)}
|
||||
{blueprint.isGlobal && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">Global</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-gray-100">
|
||||
<button type="button" onClick={onEditFields} className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors">
|
||||
Edit Fields
|
||||
</button>
|
||||
{isProject && (
|
||||
<button type="button" onClick={onEditStaffing} className="px-3 py-1.5 border border-brand-300 text-brand-700 rounded-lg hover:bg-brand-50 text-sm font-medium transition-colors">
|
||||
Edit Staffing Presets
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGlobal}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 ${
|
||||
blueprint.isGlobal
|
||||
? "border border-amber-300 text-amber-700 hover:bg-amber-50"
|
||||
: "border border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
title={blueprint.isGlobal ? "Remove from global columns" : "Make fields available as global columns"}
|
||||
>
|
||||
{blueprint.isGlobal ? "Unglobalize" : "Make Global"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete blueprint "${blueprint.name}"?`)) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 border border-red-200 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlueprintsClient() {
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
const [editingBlueprint, setEditingBlueprint] = useState<BlueprintRow | null>(null);
|
||||
const [editingTab, setEditingTab] = useState<"fields" | "presets">("fields");
|
||||
const [targetFilter, setTargetFilter] = useState<string>("");
|
||||
const [confirmBatchDelete, setConfirmBatchDelete] = useState<string[] | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data, isLoading, isError } = trpc.blueprint.list.useQuery({
|
||||
target: (targetFilter as BlueprintTarget) || undefined,
|
||||
});
|
||||
|
||||
const batchDeleteMutation = trpc.blueprint.batchDelete.useMutation();
|
||||
const deleteMutation = trpc.blueprint.delete.useMutation();
|
||||
const setGlobalMutation = trpc.blueprint.setGlobal.useMutation();
|
||||
|
||||
const viewPrefs = useViewPrefs("blueprints");
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetFilter]);
|
||||
|
||||
const blueprints: BlueprintRow[] = data ?? [];
|
||||
const { sorted: sortedBlueprints, sortField, sortDir, toggle } = useTableSort<BlueprintRow, BlueprintSortField>(blueprints, {
|
||||
initialField: (viewPrefs.savedSort?.field as BlueprintSortField | undefined) ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const blueprintIds = sortedBlueprints.map((b) => b.id);
|
||||
|
||||
function handleSort(field: BlueprintSortField) {
|
||||
switch (field) {
|
||||
case "fieldCount":
|
||||
toggle(field, (row) => (Array.isArray(row.fieldDefs) ? row.fieldDefs.length : 0));
|
||||
return;
|
||||
case "presetCount":
|
||||
toggle(field, (row) => (Array.isArray(row.rolePresets) ? row.rolePresets.length : 0));
|
||||
return;
|
||||
case "global":
|
||||
toggle(field, (row) => (row.isGlobal ? 0 : 1));
|
||||
return;
|
||||
default:
|
||||
toggle(field);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortRequest(field: string) {
|
||||
handleSort(field as BlueprintSortField);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
await deleteMutation.mutateAsync({ id });
|
||||
await utils.blueprint.list.invalidate();
|
||||
if (selection.selectedIds.has(id)) {
|
||||
selection.toggle(id);
|
||||
}
|
||||
if (editingBlueprint?.id === id) {
|
||||
setEditingBlueprint(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleGlobal(id: string, isGlobal: boolean | undefined) {
|
||||
await setGlobalMutation.mutateAsync({ id, isGlobal: !isGlobal });
|
||||
await utils.blueprint.list.invalidate();
|
||||
}
|
||||
|
||||
async function handleBatchDelete(ids: string[]) {
|
||||
await batchDeleteMutation.mutateAsync({ ids });
|
||||
await utils.blueprint.list.invalidate();
|
||||
selection.clear();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
<div className="flex items-start justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Blueprints</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Configure dynamic fields for resources and projects</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
hasActiveFilters={!!targetFilter}
|
||||
onClearFilters={() => setTargetFilter("")}
|
||||
>
|
||||
<select
|
||||
value={targetFilter}
|
||||
onChange={(e) => setTargetFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Targets</option>
|
||||
<option value="RESOURCE">Resource</option>
|
||||
<option value="PROJECT">Project</option>
|
||||
</select>
|
||||
</FilterBar>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
|
||||
Failed to load blueprints. Please refresh the page.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && blueprints.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-gray-400 text-sm mb-4">No blueprints yet. Create one to start defining dynamic fields.</p>
|
||||
<button type="button" onClick={() => setShowNewModal(true)} className={BTN_PRIMARY}>
|
||||
+ New Blueprint
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && sortedBlueprints.length > 0 && (
|
||||
<>
|
||||
<div className="hidden md:block bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="w-12 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(blueprintIds)}
|
||||
onChange={() => selection.toggleAll(blueprintIds)}
|
||||
className="rounded border-gray-300"
|
||||
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" />
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedBlueprints.map((bp) => {
|
||||
const fieldCount = Array.isArray(bp.fieldDefs) ? bp.fieldDefs.length : 0;
|
||||
const presetCount = Array.isArray(bp.rolePresets) ? bp.rolePresets.length : 0;
|
||||
const isProject = bp.target === "PROJECT";
|
||||
|
||||
return (
|
||||
<tr key={bp.id} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors">
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selectedIds.has(bp.id)}
|
||||
onChange={() => selection.toggle(bp.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900">{bp.name}</div>
|
||||
{bp.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{bp.description}</div>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${isProject ? "bg-purple-50 text-purple-700" : "bg-blue-50 text-blue-700"}`}>
|
||||
{bp.target}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center text-gray-600">{fieldCount}</td>
|
||||
<td className="px-3 py-3 text-center text-gray-600">{isProject ? presetCount : "—"}</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{bp.isGlobal ? (
|
||||
<span className="inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
Global
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit Fields
|
||||
</button>
|
||||
{isProject && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Presets
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||
disabled={setGlobalMutation.isPending}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{bp.isGlobal ? "Unglobalize" : "Make Global"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete blueprint "${bp.name}"?`)) {
|
||||
handleDelete(bp.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:hidden">
|
||||
{sortedBlueprints.map((bp) => (
|
||||
<BlueprintCard
|
||||
key={bp.id}
|
||||
blueprint={bp}
|
||||
onEditFields={() => { setEditingTab("fields"); setEditingBlueprint(bp); }}
|
||||
onEditStaffing={() => { setEditingTab("presets"); setEditingBlueprint(bp); }}
|
||||
onToggleGlobal={() => handleToggleGlobal(bp.id, bp.isGlobal)}
|
||||
onDelete={() => handleDelete(bp.id)}
|
||||
isSelected={selection.selectedIds.has(bp.id)}
|
||||
onToggleSelect={() => selection.toggle(bp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{
|
||||
label: `Delete (${selection.count})`,
|
||||
variant: "danger",
|
||||
onClick: () => setConfirmBatchDelete(selection.selectedArray),
|
||||
disabled: batchDeleteMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{confirmBatchDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Blueprints"
|
||||
message={`Delete ${confirmBatchDelete.length} selected blueprint${confirmBatchDelete.length !== 1 ? "s" : ""}? They will be marked as inactive.`}
|
||||
confirmLabel="Delete All"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
void handleBatchDelete(confirmBatchDelete);
|
||||
setConfirmBatchDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNewModal && (
|
||||
<NewBlueprintModal onClose={() => setShowNewModal(false)} onCreated={() => setShowNewModal(false)} />
|
||||
)}
|
||||
|
||||
{editingBlueprint && (
|
||||
<BlueprintFieldEditor
|
||||
blueprintId={editingBlueprint.id}
|
||||
blueprintName={editingBlueprint.name}
|
||||
initialFieldDefs={Array.isArray(editingBlueprint.fieldDefs) ? (editingBlueprint.fieldDefs as BlueprintFieldDefinition[]) : []}
|
||||
initialRolePresets={Array.isArray(editingBlueprint.rolePresets) ? (editingBlueprint.rolePresets as import("@planarchy/shared").StaffingRequirement[]) : []}
|
||||
initialTab={editingTab}
|
||||
onClose={() => setEditingBlueprint(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { StaffingRequirement } from "@planarchy/shared";
|
||||
|
||||
const INPUT_CLS =
|
||||
"px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
|
||||
|
||||
const BTN_DANGER =
|
||||
"px-2 py-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded text-sm transition-colors";
|
||||
|
||||
function makeEmptyPreset(): StaffingRequirement {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: "",
|
||||
requiredSkills: [],
|
||||
preferredSkills: [],
|
||||
hoursPerDay: 8,
|
||||
headcount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface PresetRowProps {
|
||||
preset: StaffingRequirement;
|
||||
onChange: (preset: StaffingRequirement) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function PresetRow({ preset, onChange, onDelete }: PresetRowProps) {
|
||||
function update<K extends keyof StaffingRequirement>(key: K, value: StaffingRequirement[K]) {
|
||||
onChange({ ...preset, [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-white">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Role */}
|
||||
<input
|
||||
type="text"
|
||||
value={preset.role}
|
||||
onChange={(e) => update("role", e.target.value)}
|
||||
placeholder="Role name"
|
||||
className={`${INPUT_CLS} flex-1 min-w-32`}
|
||||
aria-label="Role name"
|
||||
/>
|
||||
|
||||
{/* Required Skills */}
|
||||
<div className="flex flex-col gap-0.5 flex-1 min-w-40">
|
||||
<label className="text-xs text-gray-400">Required skills (comma-sep)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={preset.requiredSkills.join(", ")}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"requiredSkills",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="e.g. 3D Modeling, Lighting"
|
||||
className={INPUT_CLS}
|
||||
aria-label="Required skills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours per day */}
|
||||
<div className="flex flex-col gap-0.5 w-24">
|
||||
<label className="text-xs text-gray-400">h/day</label>
|
||||
<input
|
||||
type="number"
|
||||
value={preset.hoursPerDay}
|
||||
min={0}
|
||||
max={24}
|
||||
step={0.5}
|
||||
onChange={(e) => update("hoursPerDay", parseFloat(e.target.value) || 0)}
|
||||
className={INPUT_CLS}
|
||||
aria-label="Hours per day"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headcount */}
|
||||
<div className="flex flex-col gap-0.5 w-20">
|
||||
<label className="text-xs text-gray-400">Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={preset.headcount}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(e) => update("headcount", parseInt(e.target.value, 10) || 1)}
|
||||
className={INPUT_CLS}
|
||||
aria-label="Headcount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className={`${BTN_DANGER} self-end mb-0.5`}
|
||||
aria-label="Remove preset"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preferred skills (secondary row) */}
|
||||
<div className="mt-2">
|
||||
<label className="text-xs text-gray-400">Preferred skills (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(preset.preferredSkills ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
"preferredSkills",
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="e.g. Compositing, Art Direction"
|
||||
className={`${INPUT_CLS} w-full mt-0.5`}
|
||||
aria-label="Preferred skills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RolePresetsEditorProps {
|
||||
initialPresets: StaffingRequirement[];
|
||||
/** Called with the current presets array when the user clicks Save */
|
||||
onSave: (presets: StaffingRequirement[]) => void;
|
||||
isSaving?: boolean;
|
||||
saveError?: string | null;
|
||||
}
|
||||
|
||||
export function RolePresetsEditor({
|
||||
initialPresets,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
saveError = null,
|
||||
}: RolePresetsEditorProps) {
|
||||
const [presets, setPresets] = useState<StaffingRequirement[]>(initialPresets);
|
||||
|
||||
function addPreset() {
|
||||
setPresets((prev) => [...prev, makeEmptyPreset()]);
|
||||
}
|
||||
|
||||
function removePreset(idx: number) {
|
||||
setPresets((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function updatePreset(idx: number, updated: StaffingRequirement) {
|
||||
setPresets((prev) => prev.map((p, i) => (i === idx ? updated : p)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{presets.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-8">
|
||||
No role presets yet. Click “+ Add Role” to define default staffing.
|
||||
</p>
|
||||
)}
|
||||
{presets.map((preset, idx) => (
|
||||
<PresetRow
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onChange={(updated) => updatePreset(idx, updated)}
|
||||
onDelete={() => removePreset(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPreset}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
<span className="text-lg leading-none">+</span> Add Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(presets)}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Presets"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardWidgetType } from "@planarchy/shared/types";
|
||||
import { WIDGET_CATALOG } from "./widget-registry.js";
|
||||
|
||||
interface AddWidgetModalProps {
|
||||
onAdd: (type: DashboardWidgetType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AddWidgetModal({ onAdd, onClose }: AddWidgetModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Add Widget</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid of widgets */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{WIDGET_CATALOG.map((def) => (
|
||||
<button
|
||||
key={def.type}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAdd(def.type);
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-start gap-4 p-4 border border-gray-200 rounded-xl hover:border-brand-400 hover:bg-brand-50 transition-colors text-left"
|
||||
>
|
||||
<span className="text-3xl shrink-0">{def.icon}</span>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{def.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{def.description}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Default: {def.defaultSize.w}×{def.defaultSize.h} grid units
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import type { DashboardWidgetConfig, DashboardWidgetType } from "@planarchy/shared/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense, useState, useRef, useEffect } from "react";
|
||||
import { useDashboardLayout } from "~/hooks/useDashboardLayout.js";
|
||||
import { WidgetContainer } from "./WidgetContainer.js";
|
||||
import { AddWidgetModal } from "./AddWidgetModal.js";
|
||||
import { getWidget } from "./widget-registry.js";
|
||||
|
||||
// Import CSS for react-grid-layout
|
||||
import "react-grid-layout/css/styles.css";
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamic import — no WidthProvider (uses findDOMNode, broken in React 18 strict mode).
|
||||
// We measure container width ourselves via ResizeObserver and pass it as a prop.
|
||||
const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Responsive), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
|
||||
const widget = getWidget(type);
|
||||
const Component = widget.component;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<WidgetFallback />}>
|
||||
<Component config={config as Record<string, unknown>} onConfigChange={onConfigChange} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardClient() {
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const { config, addWidget, removeWidget, updateWidgetConfig, onLayoutChange, resetLayout } =
|
||||
useDashboardLayout();
|
||||
|
||||
// Measure grid container width so Responsive knows the column size.
|
||||
// We can't use WidthProvider (uses findDOMNode, deprecated in React 18).
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(1200);
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (entry) setGridWidth(entry.contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
setGridWidth(el.getBoundingClientRect().width);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const layouts = {
|
||||
lg: config.widgets.map((w) => ({
|
||||
i: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
minW: w.minW ?? 2,
|
||||
minH: w.minH ?? 2,
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Toolbar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Drag to rearrange, resize from corners</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetLayout}
|
||||
className="px-3 py-2 text-sm text-gray-500 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium 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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{config.widgets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 text-sm mb-4">No widgets yet. Add your first widget to get started.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ Add Widget
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
{(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AnyGridLayout = GridLayout as any;
|
||||
return (
|
||||
<AnyGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
width={gridWidth}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={80}
|
||||
compactType={null}
|
||||
preventCollision={false}
|
||||
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
|
||||
draggableHandle=".widget-drag-handle"
|
||||
margin={[12, 12]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetContainer
|
||||
title={widget.title ?? getWidget(widget.type).label}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
>
|
||||
{renderWidget(
|
||||
widget.type,
|
||||
widget.config,
|
||||
(update) => updateWidgetConfig(widget.id, update),
|
||||
)}
|
||||
</WidgetContainer>
|
||||
</div>
|
||||
))}
|
||||
</AnyGridLayout>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addModalOpen && (
|
||||
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
interface WidgetContainerProps {
|
||||
title: string;
|
||||
onRemove: () => void;
|
||||
children: React.ReactNode;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 bg-gray-50/50 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle">
|
||||
<span className="text-sm font-semibold text-gray-700 truncate">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-2 p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded transition-colors shrink-0"
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg className="w-3.5 h-3.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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
DASHBOARD_WIDGET_CATALOG,
|
||||
type DashboardWidgetCatalogEntry,
|
||||
type DashboardWidgetType,
|
||||
} from "@planarchy/shared/types";
|
||||
import { lazy, type ComponentType, type LazyExoticComponent } from "react";
|
||||
|
||||
type WidgetUpdate = Record<string, unknown>;
|
||||
|
||||
export interface WidgetProps {
|
||||
config: Record<string, unknown>;
|
||||
onConfigChange?: (update: WidgetUpdate) => void;
|
||||
}
|
||||
|
||||
export type WidgetComponent = LazyExoticComponent<ComponentType<WidgetProps>>;
|
||||
|
||||
export interface WidgetDefinition extends DashboardWidgetCatalogEntry {
|
||||
component: WidgetComponent;
|
||||
}
|
||||
|
||||
export const WIDGET_CATALOG = DASHBOARD_WIDGET_CATALOG;
|
||||
|
||||
export const WIDGET_REGISTRY: Record<DashboardWidgetType, WidgetDefinition> = {
|
||||
"stat-cards": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "stat-cards")!,
|
||||
component: lazy(() => import("./widgets/StatCardsWidget.js").then((m) => ({ default: m.StatCardsWidget }))),
|
||||
},
|
||||
"resource-table": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "resource-table")!,
|
||||
component: lazy(() => import("./widgets/ResourceTableWidget.js").then((m) => ({ default: m.ResourceTableWidget }))),
|
||||
},
|
||||
"project-table": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "project-table")!,
|
||||
component: lazy(() => import("./widgets/ProjectTableWidget.js").then((m) => ({ default: m.ProjectTableWidget }))),
|
||||
},
|
||||
"peak-times-chart": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "peak-times-chart")!,
|
||||
component: lazy(() => import("./widgets/PeakTimesWidget.js").then((m) => ({ default: m.PeakTimesWidget }))),
|
||||
},
|
||||
"demand-view": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "demand-view")!,
|
||||
component: lazy(() => import("./widgets/DemandWidget.js").then((m) => ({ default: m.DemandWidget }))),
|
||||
},
|
||||
"top-value-resources": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "top-value-resources")!,
|
||||
component: lazy(() => import("./widgets/TopValueWidget.js").then((m) => ({ default: m.TopValueWidget }))),
|
||||
},
|
||||
"chargeability-overview": {
|
||||
...DASHBOARD_WIDGET_CATALOG.find((widget) => widget.type === "chargeability-overview")!,
|
||||
component: lazy(() => import("./widgets/ChargeabilityWidget.js").then((m) => ({ default: m.ChargeabilityWidget }))),
|
||||
},
|
||||
};
|
||||
|
||||
export function getWidget(type: DashboardWidgetType): WidgetDefinition {
|
||||
return WIDGET_REGISTRY[type];
|
||||
}
|
||||
|
||||
export function getAllWidgets(): WidgetDefinition[] {
|
||||
return Object.values(WIDGET_REGISTRY);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
type TopSortKey = "name" | "actual" | "expected";
|
||||
type WatchSortKey = "name" | "actual" | "target";
|
||||
|
||||
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
const config = _config as { topN?: number; watchlistThreshold?: number };
|
||||
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
||||
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
|
||||
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
|
||||
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleTop(key: TopSortKey) {
|
||||
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
|
||||
}
|
||||
function toggleWatch(key: WatchSortKey) {
|
||||
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
|
||||
}
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
|
||||
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
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" />
|
||||
{[...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>
|
||||
))}
|
||||
<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" />
|
||||
{[...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 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rawTop = data?.top ?? [];
|
||||
const rawWatch = data?.watchlist ?? [];
|
||||
const month = data?.month ?? "";
|
||||
|
||||
const top = [...rawTop].sort((a, b) => {
|
||||
const mult = topDir === "asc" ? 1 : -1;
|
||||
switch (topSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const watchlist = [...rawWatch].sort((a, b) => {
|
||||
const mult = watchDir === "asc" ? 1 : -1;
|
||||
switch (watchSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function TopInd({ k }: { k: TopSortKey }) {
|
||||
return topSort === k
|
||||
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
function WatchInd({ k }: { k: WatchSortKey }) {
|
||||
return watchSort === k
|
||||
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
||||
{month && (
|
||||
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Top list */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Top Chargeability
|
||||
</h3>
|
||||
{top.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">No data available.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium w-6">#</th>
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<TopInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<TopInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Expected<TopInd k="expected" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{top.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<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>
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.expectedChargeability}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="border-t border-gray-100 flex-shrink-0" />
|
||||
|
||||
{/* Watchlist */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Watchlist <span className="font-normal text-gray-400">(below target)</span>
|
||||
</h3>
|
||||
{watchlist.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<WatchInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<WatchInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Target<WatchInd k="target" />
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{watchlist.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>}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.chargeabilityTarget}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
type GroupBy = "project" | "person" | "chapter";
|
||||
|
||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||
|
||||
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("allocatedHours");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "name" ? "asc" : "desc"); }
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||||
|
||||
const { data, isLoading, isFetching } = trpc.dashboard.getDemand.useQuery(
|
||||
{ startDate, endDate, groupBy },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="animate-pulse 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>
|
||||
{[...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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "name": return mult * a.name.localeCompare(b.name);
|
||||
case "allocatedHours": return mult * (a.allocatedHours - b.allocatedHours);
|
||||
case "requiredFTEs": return mult * ((a.requiredFTEs as unknown as number ?? 0) - (b.requiredFTEs as unknown as number ?? 0));
|
||||
case "resourceCount": return mult * (a.resourceCount - b.resourceCount);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function Ind({ k }: { k: SortKey }) {
|
||||
return sortKey === k
|
||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{(["project", "person", "chapter"] as GroupBy[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
type="button"
|
||||
onClick={() => onConfigChange?.({ groupBy: g })}
|
||||
className={`px-3 py-1.5 text-xs font-medium capitalize transition-colors ${
|
||||
groupBy === g
|
||||
? "border-b-2 border-brand-600 text-brand-700"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Per {g === "person" ? "Person" : g === "project" ? "Project" : "Chapter"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className={`overflow-auto flex-1 transition-opacity duration-150 ${isFetching ? "opacity-60" : "opacity-100"}`}>
|
||||
<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">
|
||||
<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>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("allocatedHours")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Allocated h<Ind k="allocatedHours" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Total booked hours from active assignments in the current quarter."
|
||||
position="bottom"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
{groupBy === "project" && (
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("requiredFTEs")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Req. FTEs<Ind k="requiredFTEs" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Planned demand from demand requirements, with fallback to project staffing requirements for legacy projects. Red = booked hours fall short of the planned demand."
|
||||
position="bottom"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
)}
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("resourceCount")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
{groupBy === "person" ? "Projects" : "Resources"}<Ind k="resourceCount" />
|
||||
</button>
|
||||
{groupBy === "person" ? (
|
||||
<InfoTooltip content="Number of distinct projects this person is allocated to in the period." position="bottom" />
|
||||
) : (
|
||||
<InfoTooltip content="Number of distinct resources allocated to this project/chapter in the period." position="bottom" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
|
||||
{groupBy === "project" ? (
|
||||
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
|
||||
) : (
|
||||
row.name
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
|
||||
{groupBy === "project" && (
|
||||
<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
|
||||
</span>
|
||||
) : "—";
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No demand data found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
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",
|
||||
];
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const granularity = (config.granularity as "week" | "month") || "month";
|
||||
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
|
||||
{ startDate, endDate, granularity, groupBy },
|
||||
{ staleTime: 120_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse 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>
|
||||
<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"
|
||||
style={{ height: `${30 + Math.random() * 50}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const periods = data ?? [];
|
||||
|
||||
// Collect all group names
|
||||
const allGroups = new Set<string>();
|
||||
for (const p of periods) {
|
||||
for (const g of p.groups) allGroups.add(g.name);
|
||||
}
|
||||
const groups = [...allGroups].slice(0, 10);
|
||||
|
||||
// Build recharts data
|
||||
const chartData = periods.map((p) => {
|
||||
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
|
||||
for (const g of p.groups) {
|
||||
row[g.name] = g.hours;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls + info */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={granularity}
|
||||
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="month">Monthly</option>
|
||||
<option value="week">Weekly</option>
|
||||
</select>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="project">By Project</option>
|
||||
<option value="chapter">By Chapter</option>
|
||||
<option value="resource">By Resource</option>
|
||||
</select>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
|
||||
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
|
||||
Bars exceeding the capacity line indicate over-allocation risk.
|
||||
</span>
|
||||
}
|
||||
width="w-80"
|
||||
position="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { ProjectStatus } from "@planarchy/shared/types";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const status = (config.status as ProjectStatus) || undefined;
|
||||
const search = (config.search as string) || "";
|
||||
|
||||
const { data: projects, isLoading } = trpc.project.listWithCosts.useQuery(
|
||||
{ status, search: search || undefined },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
type SortKey = "code" | "name" | "status" | "cost" | "personDays";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-2 pt-1">
|
||||
{/* header row */}
|
||||
<div className="flex gap-3 px-3 py-2">
|
||||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||||
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="h-3 w-10 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-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
}
|
||||
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "code": return mult * a.shortCode.localeCompare(b.shortCode);
|
||||
case "name": return mult * a.name.localeCompare(b.name);
|
||||
case "status": return mult * a.status.localeCompare(b.status);
|
||||
case "cost": return mult * (a.totalCostCents - b.totalCostCents);
|
||||
case "personDays": return mult * (a.totalPersonDays - b.totalPersonDays);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => onConfigChange?.({ search: e.target.value })}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<select
|
||||
value={status ?? ""}
|
||||
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{Object.values(ProjectStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<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">
|
||||
<button type="button" onClick={() => toggleSort("code")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Code
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "code" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Name
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("status")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Status
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "status" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("cost")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Cost
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "cost" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("personDays")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Person Days
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "personDays" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Total working days allocated across all non-cancelled allocations (sum of allocation durations in working days)." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{p.shortCode}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">{p.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No projects found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number;
|
||||
bookingCount: number;
|
||||
utilizationPercent: number;
|
||||
isOverbooked: boolean;
|
||||
}
|
||||
|
||||
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const chapter = (config.chapter as string) || "";
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||||
|
||||
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
|
||||
{ chapter: chapter || undefined, startDate, endDate },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
|
||||
const chapters = chapterData ?? [];
|
||||
|
||||
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-2 pt-1">
|
||||
{/* header row */}
|
||||
<div className="flex gap-3 px-3 py-2">
|
||||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||||
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="h-3 w-10 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-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (resources ?? []) as unknown as ResourceRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "eid": return mult * a.eid.localeCompare(b.eid);
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||||
case "bookings": return mult * (a.bookingCount - b.bookingCount);
|
||||
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Filter */}
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
|
||||
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<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">
|
||||
<span className="inline-flex items-center">
|
||||
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
EID
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Employee ID — unique identifier for each resource." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Name
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Chapter
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Bookings
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Utilization
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Booked hours ÷ available hours × 100 for the period.<br />
|
||||
Available hours = working days × hours from personal schedule.<br />
|
||||
<span className="text-orange-300">Orange</span> = >85% · <span className="text-red-300">Red</span> = >100%
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Target
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((r) => (
|
||||
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
|
||||
{r.utilizationPercent}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
function formatMoney(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE") + " EUR";
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
||||
{label}
|
||||
{info && <InfoTooltip content={info} />}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
||||
{sub && <span className="text-xs text-gray-400">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 h-full content-start">
|
||||
<StatCard
|
||||
label="Total Resources"
|
||||
value={data.totalResources}
|
||||
sub={`${data.activeResources} active`}
|
||||
info="All resources in the system. Sub-line shows active resources only."
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Projects"
|
||||
value={data.activeProjects}
|
||||
sub={`${data.totalProjects} total`}
|
||||
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Allocations"
|
||||
value={data.totalAllocations}
|
||||
sub={`${data.activeAllocations} not cancelled`}
|
||||
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
|
||||
/>
|
||||
<StatCard
|
||||
label="Budget Utilization"
|
||||
value={`${data.budgetSummary.avgUtilizationPercent}%`}
|
||||
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
|
||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
type SortKey = "eid" | "name" | "chapter" | "score" | "lcr";
|
||||
|
||||
export function TopValueWidget({ config }: WidgetProps) {
|
||||
const limit = (config.limit as number) || 10;
|
||||
|
||||
const [sortKey, setSortKey] = useState<SortKey>("score");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir(key === "score" ? "desc" : "asc"); }
|
||||
}
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getTopValueResources.useQuery(
|
||||
{ limit },
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-1 pt-1">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="h-3 w-4 bg-gray-200 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 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-5 w-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = data ?? [];
|
||||
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-8 text-gray-400 text-sm">
|
||||
<p>No scores computed yet or you lack access.</p>
|
||||
<p className="text-xs mt-1">Admins can recompute scores in Settings.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "eid": return mult * a.eid.localeCompare(b.eid);
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||||
case "score": return mult * ((a.valueScore ?? 0) - (b.valueScore ?? 0));
|
||||
case "lcr": return mult * (a.lcrCents - b.lcrCents);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function Ind({ k }: { k: SortKey }) {
|
||||
return sortKey === k
|
||||
? <span className="text-[10px] ml-0.5">{sortDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
}
|
||||
|
||||
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 w-8">#</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
EID<Ind k="eid" />
|
||||
</button>
|
||||
</th>
|
||||
<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">
|
||||
Name<Ind k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Chapter<Ind k="chapter" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("score")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
Score<Ind k="score" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Composite price/quality score 0–100.<br />
|
||||
Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%.<br />
|
||||
Recompute in Admin → Settings.
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("lcr")} className="inline-flex items-center hover:text-gray-700 cursor-pointer">
|
||||
LCR (€)<Ind k="lcr" />
|
||||
</button>
|
||||
<InfoTooltip content="Labour Cost Rate — hourly cost in EUR. Lower LCR = better cost efficiency score." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 text-gray-400 font-medium">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full font-semibold ${
|
||||
(r.valueScore ?? 0) >= 70
|
||||
? "bg-green-100 text-green-700"
|
||||
: (r.valueScore ?? 0) >= 40
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{r.valueScore ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{(r.lcrCents / 100).toFixed(0)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
fieldDefs: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
errors?: Record<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const INPUT_BASE =
|
||||
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
|
||||
|
||||
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900";
|
||||
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900";
|
||||
|
||||
function inputClass(hasError: boolean) {
|
||||
return clsx(INPUT_BASE, hasError ? INPUT_ERROR : INPUT_NORMAL);
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
fieldDef: BlueprintFieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
|
||||
const { key, type, placeholder, validation, options } = fieldDef;
|
||||
|
||||
switch (type) {
|
||||
case FieldType.TEXT:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
maxLength={validation?.maxLength}
|
||||
minLength={validation?.minLength}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.TEXTAREA:
|
||||
return (
|
||||
<textarea
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
maxLength={validation?.maxLength}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={clsx(inputClass(hasError), "resize-y min-h-[80px]")}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.NUMBER:
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
id={key}
|
||||
value={value !== undefined && value !== null && value !== "" ? Number(value) : ""}
|
||||
placeholder={placeholder}
|
||||
min={validation?.min}
|
||||
max={validation?.max}
|
||||
onChange={(e) =>
|
||||
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.BOOLEAN: {
|
||||
const checked = value === true || value === "true" || value === 1;
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(key, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{checked ? "Yes" : "No"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.DATE:
|
||||
return (
|
||||
<DateInput
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.SELECT:
|
||||
return (
|
||||
<select
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
>
|
||||
<option value="">{placeholder ?? "Select…"}</option>
|
||||
{(options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case FieldType.MULTI_SELECT: {
|
||||
const selectedVals = Array.isArray(value) ? value.map(String) : [];
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{(options ?? []).map((opt) => {
|
||||
const checked = selectedVals.includes(opt.value);
|
||||
return (
|
||||
<label key={opt.value} className="inline-flex items-center gap-2 cursor-pointer mr-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt.value}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked
|
||||
? [...selectedVals, opt.value]
|
||||
: selectedVals.filter((v) => v !== opt.value);
|
||||
onChange(key, next);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{opt.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.URL:
|
||||
return (
|
||||
<input
|
||||
type="url"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder ?? "https://"}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
case FieldType.EMAIL:
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder ?? "email@example.com"}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
id={key}
|
||||
value={typeof value === "string" ? value : (value ?? "") as string}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(key, e.target.value)}
|
||||
className={inputClass(hasError)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
fieldDef: BlueprintFieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
|
||||
const hasError = Boolean(error);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={fieldDef.key}
|
||||
className="text-sm font-medium text-gray-700"
|
||||
>
|
||||
{fieldDef.label}
|
||||
{fieldDef.required && (
|
||||
<span className="ml-0.5 text-red-500" aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<FieldInput
|
||||
fieldDef={fieldDef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
{fieldDef.description && !error && (
|
||||
<p className="text-xs text-gray-400">{fieldDef.description}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({
|
||||
group,
|
||||
fields,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
}: {
|
||||
group: string;
|
||||
fields: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-4 border border-gray-200 rounded-lg p-4">
|
||||
<legend className="text-sm font-semibold text-gray-700 px-1">{group}</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<FieldWrapper
|
||||
key={field.id}
|
||||
fieldDef={field}
|
||||
value={values[field.key]}
|
||||
onChange={onChange}
|
||||
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicFieldEditor({
|
||||
fieldDefs,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
className,
|
||||
}: Props) {
|
||||
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
|
||||
|
||||
const ungrouped = sorted.filter((f) => !f.group);
|
||||
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
|
||||
|
||||
for (const field of sorted) {
|
||||
if (!field.group) continue;
|
||||
const existing = groupMap.get(field.group) ?? [];
|
||||
existing.push(field);
|
||||
groupMap.set(field.group, existing);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-6", className)}>
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ungrouped.map((field) => (
|
||||
<FieldWrapper
|
||||
key={field.id}
|
||||
fieldDef={field}
|
||||
value={values[field.key]}
|
||||
onChange={onChange}
|
||||
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{[...groupMap.entries()].map(([group, fields]) => (
|
||||
<FieldGroup
|
||||
key={group}
|
||||
group={group}
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
{...(errors !== undefined ? { errors } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { FieldType } from "@planarchy/shared";
|
||||
import type { BlueprintFieldDefinition } from "@planarchy/shared";
|
||||
|
||||
interface Props {
|
||||
fieldDefs: BlueprintFieldDefinition[];
|
||||
values: Record<string, unknown>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
switch (fieldDef.type) {
|
||||
case FieldType.TEXT:
|
||||
case FieldType.TEXTAREA:
|
||||
case FieldType.URL:
|
||||
case FieldType.EMAIL:
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
|
||||
case FieldType.NUMBER:
|
||||
return (
|
||||
<span className="text-gray-900">
|
||||
{typeof value === "number" ? value.toLocaleString() : String(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
case FieldType.BOOLEAN: {
|
||||
const bool = value === true || value === "true" || value === 1;
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
bool
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{bool ? "Yes" : "No"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
case FieldType.DATE: {
|
||||
const dateStr = String(value);
|
||||
const parsed = new Date(dateStr);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return <span className="text-gray-900">{dateStr}</span>;
|
||||
}
|
||||
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
|
||||
}
|
||||
|
||||
case FieldType.SELECT: {
|
||||
const strVal = String(value);
|
||||
const option = fieldDef.options?.find((o) => o.value === strVal);
|
||||
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
|
||||
}
|
||||
|
||||
case FieldType.MULTI_SELECT: {
|
||||
const rawVals = Array.isArray(value) ? value : [value];
|
||||
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
|
||||
|
||||
if (strVals.length === 0) {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
const labels = strVals.map((v) => {
|
||||
const option = fieldDef.options?.find((o) => o.value === v);
|
||||
return { value: v, label: option?.label ?? v, color: option?.color };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{labels.map(({ value: v, label }) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return <span className="text-gray-900">{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{fieldDef.label}
|
||||
</dt>
|
||||
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
|
||||
{fieldDef.description && (
|
||||
<p className="text-xs text-gray-400">{fieldDef.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
|
||||
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Separate grouped and ungrouped fields
|
||||
const ungrouped = sorted.filter((f) => !f.group);
|
||||
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
|
||||
|
||||
for (const field of sorted) {
|
||||
if (!field.group) continue;
|
||||
const existing = groupMap.get(field.group) ?? [];
|
||||
existing.push(field);
|
||||
groupMap.set(field.group, existing);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-6", className)}>
|
||||
{ungrouped.length > 0 && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{ungrouped.map((field) => (
|
||||
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{[...groupMap.entries()].map(([group, fields]) => (
|
||||
<div key={group} className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
|
||||
{group}
|
||||
</h4>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DynamicFieldRenderer } from "./DynamicFieldRenderer.js";
|
||||
export { DynamicFieldEditor } from "./DynamicFieldEditor.js";
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyEffortRulesProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
|
||||
|
||||
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
|
||||
const [mode, setMode] = useState<"replace" | "append">("replace");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.effortRule.preview.useQuery(
|
||||
{ estimateId, ruleSetId: selectedRuleSetId },
|
||||
{ enabled: showPreview && Boolean(selectedRuleSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.effortRule.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
if (result.warnings.length > 0) {
|
||||
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default rule set
|
||||
if (!selectedRuleSetId && ruleSets) {
|
||||
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedRuleSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
|
||||
}
|
||||
|
||||
if (!ruleSets || ruleSets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No effort rule sets defined.{" "}
|
||||
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to auto-generate demand lines from scope items.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Generate demand lines from scope</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
|
||||
<select
|
||||
value={selectedRuleSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedRuleSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{ruleSets.map((rs) => (
|
||||
<option key={rs.id} value={rs.id}>
|
||||
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as "replace" | "append")}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
<option value="replace">Replace existing lines</option>
|
||||
<option value="append">Append to existing</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedRuleSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedRuleSetId) return;
|
||||
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
|
||||
if (confirm(`This will ${action}. Continue?`)) {
|
||||
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedRuleSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Generating..." : "Apply rules"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.scopeItemCount} scope items</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
|
||||
{previewQuery.data.unmatchedScopeItems.length > 0 && (
|
||||
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewQuery.data.warnings.length > 0 && (
|
||||
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
|
||||
{previewQuery.data.warnings.map((w, i) => (
|
||||
<p key={i}>{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aggregated discipline summary */}
|
||||
{previewQuery.data.aggregated.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<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">Discipline</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Total hours</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.aggregated.map((agg, i) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detailed lines (collapsible) */}
|
||||
{previewQuery.data.lines.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.lines.length} generated lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<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 item</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">Mode</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Units</th>
|
||||
<th className="px-3 py-2 text-right font-medium">h/unit</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.lines.map((line, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
|
||||
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
|
||||
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyExperienceMultipliersProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||
|
||||
const [selectedSetId, setSelectedSetId] = useState<string>("");
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
|
||||
{ estimateId, multiplierSetId: selectedSetId },
|
||||
{ enabled: showPreview && Boolean(selectedSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
onApplied?.();
|
||||
alert(
|
||||
`Updated ${result.linesUpdated} demand line(s).\n` +
|
||||
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select default set
|
||||
if (!selectedSetId && sets) {
|
||||
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
|
||||
if (defaultSet) {
|
||||
setSelectedSetId(defaultSet.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
|
||||
}
|
||||
|
||||
if (!sets || sets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
|
||||
No experience multiplier sets defined.{" "}
|
||||
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
|
||||
Create one
|
||||
</a>{" "}
|
||||
to apply rate and effort adjustments.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers</h3>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
|
||||
<select
|
||||
value={selectedSetId}
|
||||
onChange={(e) => {
|
||||
setSelectedSetId(e.target.value);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
{sets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
disabled={!selectedSetId}
|
||||
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{showPreview ? "Hide preview" : "Preview"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!selectedSetId) return;
|
||||
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
|
||||
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
|
||||
}
|
||||
}}
|
||||
disabled={!selectedSetId || applyMutation.isPending}
|
||||
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applyMutation.error && (
|
||||
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && previewQuery.data && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
|
||||
<span>{previewQuery.data.demandLineCount} demand lines</span>
|
||||
<span>{previewQuery.data.ruleCount} rules</span>
|
||||
<span className="font-semibold text-brand-700">
|
||||
{previewQuery.data.linesChanged} line(s) would be adjusted
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{previewQuery.data.linesChanged > 0 && (
|
||||
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
|
||||
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
|
||||
{formatCents(previewQuery.data.totalAdjustedCostCents)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-line preview */}
|
||||
{previewQuery.data.previews.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
Show all {previewQuery.data.previews.length} lines
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Chapter</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||
<th className="pl-3 py-2 font-medium">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewQuery.data.previews.map((p, i) => (
|
||||
<tr
|
||||
key={p.demandLineId}
|
||||
className={clsx(
|
||||
"border-b border-gray-100",
|
||||
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedCostRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalCostRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
|
||||
{formatCents(p.adjustedBillRateCents)}
|
||||
</>
|
||||
) : (
|
||||
formatCents(p.originalBillRateCents)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
|
||||
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
|
||||
{p.adjustedHours}h
|
||||
</>
|
||||
) : (
|
||||
`${p.originalHours}h`
|
||||
)}
|
||||
</td>
|
||||
<td className="pl-3 py-1.5 text-xs text-gray-500">
|
||||
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPreview && previewQuery.isLoading && (
|
||||
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EstimateStatus } from "@planarchy/shared";
|
||||
import { computeEvenSpread } from "@planarchy/engine";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||
import { clsx } from "clsx";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const INPUT_CLS =
|
||||
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||
const SELECT_CLS = INPUT_CLS;
|
||||
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
|
||||
|
||||
interface AssumptionRow {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ScopeRow {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface DemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
roleId: string | null;
|
||||
resourceId: string | null;
|
||||
hours: string;
|
||||
chapter: string;
|
||||
costRate: string;
|
||||
billRate: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResourceOption {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
roleId: string | null;
|
||||
federalState: string | null;
|
||||
}
|
||||
|
||||
function makeAssumption(): AssumptionRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
category: "commercial",
|
||||
key: "",
|
||||
label: "",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeScope(sequenceNo = 1): ScopeRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo,
|
||||
scopeType: "SHOT",
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDemand(): DemandRow {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
roleId: null,
|
||||
resourceId: null,
|
||||
hours: "8",
|
||||
chapter: "",
|
||||
costRate: "",
|
||||
billRate: "",
|
||||
currency: "EUR",
|
||||
};
|
||||
}
|
||||
|
||||
function toCents(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(parsed * 100);
|
||||
}
|
||||
|
||||
function toHours(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
export function EstimateWizard({ onClose }: { onClose: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [opportunityId, setOpportunityId] = useState("");
|
||||
const [baseCurrency, setBaseCurrency] = useState("EUR");
|
||||
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
|
||||
const [versionLabel, setVersionLabel] = useState("Initial");
|
||||
const [versionNotes, setVersionNotes] = useState("");
|
||||
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
|
||||
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
|
||||
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(panelRef, true);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
|
||||
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
|
||||
const resourcesQuery = trpc.resource.list.useQuery(
|
||||
{ limit: 500, includeRoles: true, isActive: true },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const createMutation = trpc.estimate.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.estimate.list.invalidate();
|
||||
onClose();
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
setError(mutationError.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
|
||||
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
|
||||
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
|
||||
|
||||
const projects: ProjectOption[] = projectRows.map((project) => ({
|
||||
id: project.id,
|
||||
shortCode: project.shortCode,
|
||||
name: project.name,
|
||||
}));
|
||||
const roles: RoleOption[] = roleRows.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
}));
|
||||
const resources: ResourceOption[] = resourceRows.map((resource) => ({
|
||||
id: resource.id,
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
roleId: resource.roleId,
|
||||
federalState: resource.federalState,
|
||||
}));
|
||||
|
||||
const selectedProject = projectId
|
||||
? projects.find((project) => project.id === projectId) ?? null
|
||||
: null;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return demandLines.reduce(
|
||||
(accumulator, line) => {
|
||||
const hours = toHours(line.hours);
|
||||
const costTotalCents = Math.round(hours * toCents(line.costRate));
|
||||
const priceTotalCents = Math.round(hours * toCents(line.billRate));
|
||||
|
||||
return {
|
||||
totalHours: accumulator.totalHours + hours,
|
||||
totalCostCents: accumulator.totalCostCents + costTotalCents,
|
||||
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
|
||||
};
|
||||
},
|
||||
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
|
||||
);
|
||||
}, [demandLines]);
|
||||
|
||||
const marginCents = summary.totalPriceCents - summary.totalCostCents;
|
||||
const marginPercent = summary.totalPriceCents > 0
|
||||
? Math.round((marginCents / summary.totalPriceCents) * 100)
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
|
||||
setAssumptions((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
|
||||
setScopeItems((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
|
||||
setDemandLines((current) =>
|
||||
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
function applyResource(resourceId: string | null, demandLineId: string) {
|
||||
const resource = resourceId
|
||||
? resources.find((item) => item.id === resourceId) ?? null
|
||||
: null;
|
||||
|
||||
updateDemandLine(demandLineId, {
|
||||
resourceId,
|
||||
name: resource?.displayName ?? "",
|
||||
chapter: resource?.chapter ?? "",
|
||||
currency: resource?.currency ?? baseCurrency,
|
||||
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
|
||||
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
|
||||
roleId: resource?.roleId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
|
||||
if (!isSpreadsheetFile(file)) {
|
||||
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await parseScopeImport(file);
|
||||
setScopeImportWarnings(result.warnings);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const imported: ScopeRow[] = result.rows.map((row) => ({
|
||||
id: crypto.randomUUID(),
|
||||
sequenceNo: row.sequenceNo,
|
||||
scopeType: row.scopeType,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
}));
|
||||
setScopeItems((current) => {
|
||||
const nonEmpty = current.filter((item) => item.name.trim());
|
||||
return [...nonEmpty, ...imported];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStep(targetStep: number) {
|
||||
if (targetStep === 1 && !name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
|
||||
if (!validateStep(nextStep)) return;
|
||||
setStep(nextStep);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setStep((current) => Math.max(current - 1, 0));
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Estimate name is required.");
|
||||
setStep(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDemandLines = demandLines
|
||||
.map((line, index) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
const role = line.roleId
|
||||
? roles.find((item) => item.id === line.roleId) ?? null
|
||||
: null;
|
||||
const hours = toHours(line.hours);
|
||||
const costRateCents = toCents(line.costRate);
|
||||
const billRateCents = toCents(line.billRate);
|
||||
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
|
||||
|
||||
return {
|
||||
resourceId: line.resourceId ?? undefined,
|
||||
roleId: line.roleId ?? undefined,
|
||||
lineType: "LABOR",
|
||||
name: displayName,
|
||||
chapter: line.chapter || resource?.chapter || undefined,
|
||||
hours,
|
||||
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
|
||||
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
|
||||
costRateCents,
|
||||
billRateCents,
|
||||
currency: line.currency || resource?.currency || baseCurrency,
|
||||
costTotalCents: Math.round(hours * costRateCents),
|
||||
priceTotalCents: Math.round(hours * billRateCents),
|
||||
monthlySpread:
|
||||
selectedProject?.startDate && selectedProject?.endDate && hours > 0
|
||||
? computeEvenSpread({
|
||||
totalHours: hours,
|
||||
startDate: new Date(selectedProject.startDate),
|
||||
endDate: new Date(selectedProject.endDate),
|
||||
}).spread
|
||||
: {},
|
||||
staffingAttributes: {
|
||||
linkedResource: resource ? true : false,
|
||||
linkedRole: role ? true : false,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
})
|
||||
.filter((line) => line.hours > 0);
|
||||
|
||||
const normalizedScopeItems = scopeItems
|
||||
.map((item, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
scopeType: item.scopeType.trim() || "SHOT",
|
||||
name: item.name.trim(),
|
||||
description: item.description.trim() || undefined,
|
||||
technicalSpec: {},
|
||||
sortOrder: index,
|
||||
metadata: {},
|
||||
}))
|
||||
.filter((item) => item.name.length > 0);
|
||||
|
||||
const normalizedAssumptions = assumptions
|
||||
.map((assumption, index) => ({
|
||||
category: assumption.category.trim() || "general",
|
||||
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
|
||||
label: assumption.label.trim(),
|
||||
valueType: "text",
|
||||
value: assumption.value.trim(),
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
|
||||
|
||||
const seenResources = new Set<string>();
|
||||
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
|
||||
if (!line.resourceId) return [];
|
||||
if (seenResources.has(line.resourceId)) return [];
|
||||
seenResources.add(line.resourceId);
|
||||
|
||||
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
|
||||
if (!resource) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
resourceId: resource.id,
|
||||
sourceEid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter ?? undefined,
|
||||
roleId: resource.roleId ?? undefined,
|
||||
currency: resource.currency,
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
location: resource.federalState ?? undefined,
|
||||
attributes: {},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
createMutation.mutate({
|
||||
projectId: projectId ?? undefined,
|
||||
name: name.trim(),
|
||||
opportunityId: opportunityId.trim() || undefined,
|
||||
baseCurrency,
|
||||
status,
|
||||
versionLabel: versionLabel.trim() || undefined,
|
||||
versionNotes: versionNotes.trim() || undefined,
|
||||
assumptions: normalizedAssumptions,
|
||||
scopeItems: normalizedScopeItems,
|
||||
demandLines: normalizedDemandLines,
|
||||
resourceSnapshots,
|
||||
metrics: [],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
|
||||
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
|
||||
<div className="border-b border-gray-100 px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 md:grid-cols-5">
|
||||
{STEP_LABELS.map((label, index) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (index <= step || validateStep(index)) {
|
||||
setStep(index);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
"rounded-2xl px-4 py-3 text-left transition",
|
||||
index === step
|
||||
? "bg-brand-600 text-white"
|
||||
: index < step
|
||||
? "bg-brand-50 text-brand-700"
|
||||
: "bg-gray-50 text-gray-400",
|
||||
)}
|
||||
>
|
||||
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
|
||||
<span className="mt-1 block text-sm font-semibold">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-6">
|
||||
{step === 0 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Name</label>
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Linked Project</label>
|
||||
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Opportunity ID</label>
|
||||
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Estimate Status</label>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Base Currency</label>
|
||||
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Label</label>
|
||||
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Version Notes</label>
|
||||
<textarea
|
||||
value={versionNotes}
|
||||
onChange={(event) => setVersionNotes(event.target.value)}
|
||||
rows={5}
|
||||
className={INPUT_CLS}
|
||||
placeholder="Document assumptions, exclusions, or client comments."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{roles.length} roles, {resources.length} active resources available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
|
||||
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add assumption
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assumptions.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
|
||||
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
|
||||
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
|
||||
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
|
||||
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
|
||||
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
|
||||
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Import XLSX
|
||||
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
|
||||
</label>
|
||||
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add scope row
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopeImportWarnings.length > 0 && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
{scopeImportWarnings.map((warning, index) => (
|
||||
<p key={index}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{scopeItems.map((item, index) => (
|
||||
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
|
||||
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
|
||||
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
|
||||
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
|
||||
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
|
||||
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
|
||||
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
Add staffing line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{demandLines.map((line) => {
|
||||
const resource = line.resourceId
|
||||
? resources.find((item) => item.id === line.resourceId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Resource</label>
|
||||
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Role</label>
|
||||
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
|
||||
<option value="">Unassigned</option>
|
||||
{roles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Line Name</label>
|
||||
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Chapter</label>
|
||||
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Hours</label>
|
||||
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Currency</label>
|
||||
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Cost Rate / h</label>
|
||||
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Sell Rate / h</label>
|
||||
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
|
||||
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-gray-900">
|
||||
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Name</dt>
|
||||
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Project</dt>
|
||||
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Status</dt>
|
||||
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Version</dt>
|
||||
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-3xl border border-gray-100 p-5">
|
||||
<p className="text-sm font-semibold text-gray-900">Connected records</p>
|
||||
<dl className="mt-4 space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Assumptions</dt>
|
||||
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Scope items</dt>
|
||||
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Demand lines</dt>
|
||||
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Resource snapshots</dt>
|
||||
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
|
||||
<p className="mt-1 text-sm text-gray-800">
|
||||
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
|
||||
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
|
||||
{step === 0 ? "Cancel" : "Back"}
|
||||
</button>
|
||||
{step < STEP_LABELS.length - 1 ? (
|
||||
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{createMutation.isPending ? "Creating..." : "Create Estimate"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
EstimateDemandLineCalculationMetadata,
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateDemandLineRateMode,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
interface ResourceRateSnapshotLike {
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
|
||||
return value === "resource" || value === "manual" ? value : undefined;
|
||||
}
|
||||
|
||||
export function parseDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): EstimateDemandLineMetadata {
|
||||
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return metadata as EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export function resolveDemandLineCalculationMetadata(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
metadata?: Record<string, unknown> | null | undefined;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
}): EstimateDemandLineCalculationMetadata {
|
||||
const resourceSnapshot = options.resourceSnapshot;
|
||||
const parsedMetadata = parseDemandLineMetadata(options.metadata);
|
||||
const calculation =
|
||||
typeof parsedMetadata.calculation === "object" &&
|
||||
parsedMetadata.calculation !== null
|
||||
? parsedMetadata.calculation
|
||||
: undefined;
|
||||
const costRateMode =
|
||||
parseRateMode(calculation?.costRateMode) ??
|
||||
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
const billRateMode =
|
||||
parseRateMode(calculation?.billRateMode) ??
|
||||
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
|
||||
? "resource"
|
||||
: "manual");
|
||||
|
||||
return {
|
||||
costRateMode,
|
||||
billRateMode,
|
||||
totalMode: "computed",
|
||||
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
|
||||
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
|
||||
liveCurrency: resourceSnapshot?.currency ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDemandLineMetadata(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
calculation: EstimateDemandLineCalculationMetadata,
|
||||
): EstimateDemandLineMetadata {
|
||||
return {
|
||||
...parseDemandLineMetadata(metadata),
|
||||
calculation,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveDemandLineValues(options: {
|
||||
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
|
||||
hours: number;
|
||||
currency?: string | null;
|
||||
defaultCurrency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costRateMode: EstimateDemandLineRateMode;
|
||||
billRateMode: EstimateDemandLineRateMode;
|
||||
}) {
|
||||
const effectiveCostRateCents =
|
||||
options.costRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.lcrCents
|
||||
: options.costRateCents;
|
||||
const effectiveBillRateCents =
|
||||
options.billRateMode === "resource" && options.resourceSnapshot
|
||||
? options.resourceSnapshot.ucrCents
|
||||
: options.billRateCents;
|
||||
const currency =
|
||||
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
|
||||
options.resourceSnapshot?.currency
|
||||
? options.resourceSnapshot.currency
|
||||
: options.currency) ||
|
||||
options.resourceSnapshot?.currency ||
|
||||
options.defaultCurrency;
|
||||
|
||||
return {
|
||||
effectiveCostRateCents,
|
||||
effectiveBillRateCents,
|
||||
currency,
|
||||
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
|
||||
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateExportArtifactPayload,
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
EstimateVersionStatus,
|
||||
} from "@planarchy/shared";
|
||||
|
||||
export interface EstimateMetricView {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueDecimal: number;
|
||||
valueCents?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateAssumptionView {
|
||||
id: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: unknown;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateScopeItemView {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
scopeType: string;
|
||||
packageCode?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
frameCount?: number | null;
|
||||
itemCount?: number | null;
|
||||
unitMode?: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateDemandLineView {
|
||||
id: string;
|
||||
scopeItemId?: string | null;
|
||||
roleId?: string | null;
|
||||
resourceId?: string | null;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter?: string | null;
|
||||
rateSource?: string | null;
|
||||
hours: number;
|
||||
currency: string;
|
||||
costRateCents: number;
|
||||
billRateCents: number;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
monthlySpread?: Record<string, number>;
|
||||
metadata: EstimateDemandLineMetadata;
|
||||
}
|
||||
|
||||
export interface EstimateResourceSnapshotView {
|
||||
id: string;
|
||||
resourceId?: string | null;
|
||||
sourceEid?: string | null;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
currency: string;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
fte?: number | null;
|
||||
location?: string | null;
|
||||
country?: string | null;
|
||||
level?: string | null;
|
||||
workType?: string | null;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EstimateExportView {
|
||||
id: string;
|
||||
fileName: string;
|
||||
format: EstimateExportFormat;
|
||||
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface EstimateVersionView {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
label?: string | null;
|
||||
status: EstimateVersionStatus;
|
||||
notes?: string | null;
|
||||
lockedAt?: Date | null;
|
||||
updatedAt: Date;
|
||||
assumptions: EstimateAssumptionView[];
|
||||
scopeItems: EstimateScopeItemView[];
|
||||
demandLines: EstimateDemandLineView[];
|
||||
resourceSnapshots: EstimateResourceSnapshotView[];
|
||||
metrics: EstimateMetricView[];
|
||||
exports: EstimateExportView[];
|
||||
}
|
||||
|
||||
export interface EstimateWorkspaceView {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EstimateStatus;
|
||||
projectId?: string | null;
|
||||
opportunityId?: string | null;
|
||||
baseCurrency: string;
|
||||
updatedAt: Date;
|
||||
project?: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
startDate?: string | Date | null;
|
||||
endDate?: string | Date | null;
|
||||
} | null;
|
||||
versions: EstimateVersionView[];
|
||||
}
|
||||
|
||||
export type WorkspaceTab =
|
||||
| "overview"
|
||||
| "assumptions"
|
||||
| "scope"
|
||||
| "staffing"
|
||||
| "financials"
|
||||
| "phasing"
|
||||
| "versions"
|
||||
| "exports";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
type ChapterSubtotal,
|
||||
type ResourceSnapshotDiff,
|
||||
type ScopeItemDiff,
|
||||
} from "@planarchy/engine";
|
||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatDelta(value: number, formatter: (v: number) => string) {
|
||||
const prefix = value > 0 ? "+" : "";
|
||||
return `${prefix}${formatter(value)}`;
|
||||
}
|
||||
|
||||
function formatHoursDelta(delta: number) {
|
||||
const prefix = delta > 0 ? "+" : "";
|
||||
return `${prefix}${delta.toFixed(1)} h`;
|
||||
}
|
||||
|
||||
function versionToInput(v: EstimateVersionView): VersionCompareInput {
|
||||
return {
|
||||
label: v.label ?? null,
|
||||
versionNumber: v.versionNumber,
|
||||
demandLines: v.demandLines.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
hours: l.hours,
|
||||
costRateCents: l.costRateCents,
|
||||
billRateCents: l.billRateCents,
|
||||
costTotalCents: l.costTotalCents,
|
||||
priceTotalCents: l.priceTotalCents,
|
||||
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
|
||||
lineType: l.lineType,
|
||||
})),
|
||||
assumptions: v.assumptions.map((a) => ({
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
value: a.value,
|
||||
})),
|
||||
scopeItems: v.scopeItems.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
sequenceNo: s.sequenceNo,
|
||||
scopeType: s.scopeType,
|
||||
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
|
||||
...(s.description !== undefined ? { description: s.description } : {}),
|
||||
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
|
||||
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
|
||||
})),
|
||||
resourceSnapshots: v.resourceSnapshots.map((r) => ({
|
||||
id: r.id,
|
||||
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
|
||||
displayName: r.displayName,
|
||||
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
|
||||
currency: r.currency,
|
||||
lcrCents: r.lcrCents,
|
||||
ucrCents: r.ucrCents,
|
||||
...(r.location !== undefined ? { location: r.location } : {}),
|
||||
...(r.level !== undefined ? { level: r.level } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ROW_STYLES = {
|
||||
added: "bg-emerald-50",
|
||||
removed: "bg-red-50",
|
||||
changed: "bg-amber-50",
|
||||
unchanged: "",
|
||||
} as const;
|
||||
|
||||
const STATUS_BADGE_STYLES = {
|
||||
added: "bg-emerald-100 text-emerald-700",
|
||||
removed: "bg-red-100 text-red-700",
|
||||
changed: "bg-amber-100 text-amber-700",
|
||||
unchanged: "bg-gray-100 text-gray-500",
|
||||
} as const;
|
||||
|
||||
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
|
||||
const sorted = useMemo(
|
||||
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
|
||||
[versions],
|
||||
);
|
||||
|
||||
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
|
||||
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
|
||||
const [hideUnchanged, setHideUnchanged] = useState(false);
|
||||
|
||||
const versionA = sorted.find((v) => v.id === aId);
|
||||
const versionB = sorted.find((v) => v.id === bId);
|
||||
|
||||
const diff = useMemo(() => {
|
||||
if (!versionA || !versionB || versionA.id === versionB.id) return null;
|
||||
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
|
||||
}, [versionA, versionB]);
|
||||
|
||||
if (sorted.length < 2) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
||||
At least two versions are required to compare.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredDemandDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.demandLineDiffs
|
||||
: [];
|
||||
|
||||
const filteredAssumptionDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.assumptionDiffs
|
||||
: [];
|
||||
|
||||
const filteredScopeDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.scopeItemDiffs
|
||||
: [];
|
||||
|
||||
const filteredResourceDiffs = diff
|
||||
? hideUnchanged
|
||||
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
|
||||
: diff.resourceSnapshotDiffs
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Version selectors */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
|
||||
<select
|
||||
value={aId}
|
||||
onChange={(e) => setAId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className="pb-2 text-sm text-gray-400">vs</span>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
|
||||
<select
|
||||
value={bId}
|
||||
onChange={(e) => setBId(e.target.value)}
|
||||
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
||||
>
|
||||
{sorted.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideUnchanged}
|
||||
onChange={(e) => setHideUnchanged(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Hide unchanged
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{aId === bId && (
|
||||
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<SummaryCard
|
||||
label="Hours"
|
||||
value={formatHoursDelta(diff.summary.totalHoursDelta)}
|
||||
positive={diff.summary.totalHoursDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Cost"
|
||||
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalCostDelta <= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Price"
|
||||
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
|
||||
positive={diff.summary.totalPriceDelta >= 0}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Margin"
|
||||
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
|
||||
positive={diff.summary.marginPercentDelta >= 0}
|
||||
/>
|
||||
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
|
||||
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
|
||||
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
|
||||
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
|
||||
</div>
|
||||
|
||||
{/* Demand line diffs */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
|
||||
{filteredDemandDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredDemandDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
|
||||
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
|
||||
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
||||
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
|
||||
</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
|
||||
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assumption diffs */}
|
||||
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
|
||||
{filteredAssumptionDiffs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-400">
|
||||
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">Assumption</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Value (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Value (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssumptionDiffs.map((d) => (
|
||||
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
|
||||
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapter subtotals */}
|
||||
{diff.chapterSubtotals.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<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 text-right font-medium">Hours (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diff.chapterSubtotals.map((ch) => (
|
||||
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
|
||||
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
|
||||
{formatHoursDelta(ch.hoursDelta)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
|
||||
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
|
||||
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scope item diffs */}
|
||||
{filteredScopeDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">
|
||||
Scope items
|
||||
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
|
||||
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
|
||||
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Type</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
|
||||
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScopeDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resource snapshot diffs */}
|
||||
{filteredResourceDiffs.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
|
||||
<th className="px-3 py-2 font-medium">Location (A)</th>
|
||||
<th className="pl-3 py-2 font-medium">Location (B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResourceDiffs.map((d, i) => (
|
||||
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
||||
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
|
||||
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
positive,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
positive: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
||||
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaColor(delta: number | undefined): string {
|
||||
if (delta == null || delta === 0) return "text-gray-400";
|
||||
return delta > 0 ? "text-red-600" : "text-emerald-600";
|
||||
}
|
||||
|
||||
function formatAssumptionValue(value: unknown): string {
|
||||
if (value === undefined) return "\u2014";
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface WeeklyPhasingViewProps {
|
||||
estimateId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = "by_line" | "by_chapter";
|
||||
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
|
||||
|
||||
function getDefaultDateRange(): { start: string; end: string } {
|
||||
const now = new Date();
|
||||
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
|
||||
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function heatColor(hours: number, maxHours: number): string {
|
||||
if (hours === 0 || maxHours === 0) return "";
|
||||
const ratio = Math.min(hours / maxHours, 1);
|
||||
if (ratio < 0.25) return "bg-blue-50";
|
||||
if (ratio < 0.5) return "bg-blue-100";
|
||||
if (ratio < 0.75) return "bg-blue-200";
|
||||
return "bg-blue-300";
|
||||
}
|
||||
|
||||
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
|
||||
const defaults = getDefaultDateRange();
|
||||
const [startDate, setStartDate] = useState(defaults.start);
|
||||
const [endDate, setEndDate] = useState(defaults.end);
|
||||
const [pattern, setPattern] = useState<PhasingPattern>("even");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
|
||||
{ estimateId },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
|
||||
void utils.estimate.getById.invalidate({ id: estimateId });
|
||||
},
|
||||
});
|
||||
|
||||
const data = phasingQuery.data;
|
||||
|
||||
// Compute max hours for heat-map coloring
|
||||
const maxHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const line of data.lines) {
|
||||
for (const h of Object.values(line.weeklyHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
// Compute column totals
|
||||
const columnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const line of data.lines) {
|
||||
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute chapter column totals
|
||||
const chapterColumnTotals = useMemo(() => {
|
||||
if (!data?.hasPhasing) return {};
|
||||
const totals: Record<string, number> = {};
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const [weekKey, hours] of Object.entries(chapterHours)) {
|
||||
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
|
||||
}
|
||||
}
|
||||
return totals;
|
||||
}, [data]);
|
||||
|
||||
// Compute max hours for chapter view
|
||||
const maxChapterHours = useMemo(() => {
|
||||
if (!data?.hasPhasing) return 0;
|
||||
let max = 0;
|
||||
for (const chapterHours of Object.values(data.chapterAggregation)) {
|
||||
for (const h of Object.values(chapterHours)) {
|
||||
if (h > max) max = h;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}, [data]);
|
||||
|
||||
const handleGenerate = () => {
|
||||
generateMutation.mutate({
|
||||
estimateId,
|
||||
startDate,
|
||||
endDate,
|
||||
pattern,
|
||||
});
|
||||
};
|
||||
|
||||
// Use config dates from existing phasing if available
|
||||
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
|
||||
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header / Controls */}
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900">
|
||||
Weekly Phasing (4Dispo)
|
||||
</h3>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveStart : startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={data?.hasPhasing ? effectiveEnd : endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Pattern
|
||||
</label>
|
||||
<select
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="even">Even Distribution</option>
|
||||
<option value="front_loaded">Front Loaded (60/40)</option>
|
||||
<option value="back_loaded">Back Loaded (40/60)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending}
|
||||
className={clsx(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium text-white",
|
||||
generateMutation.isPending
|
||||
? "cursor-not-allowed bg-gray-400"
|
||||
: "bg-sky-600 hover:bg-sky-700",
|
||||
)}
|
||||
>
|
||||
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateMutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{generateMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generateMutation.isSuccess && (
|
||||
<p className="mt-2 text-sm text-emerald-600">
|
||||
Phasing generated for {generateMutation.data.linesUpdated} demand
|
||||
lines.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
{data?.hasPhasing && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_line")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_line"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Line
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("by_chapter")}
|
||||
className={clsx(
|
||||
"rounded-lg px-3 py-1.5 text-sm font-medium",
|
||||
viewMode === "by_chapter"
|
||||
? "bg-sky-100 text-sky-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
>
|
||||
By Chapter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phasing Grid */}
|
||||
{phasingQuery.isLoading && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
Loading phasing data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !data.hasPhasing && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
|
||||
No weekly phasing generated yet. Use the controls above to generate a
|
||||
phasing distribution.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_line" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Demand Line
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.lines.map((line) => {
|
||||
const lineTotal = Object.values(line.weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={line.id}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
<div className="truncate max-w-[200px]" title={line.name}>
|
||||
{line.name}
|
||||
</div>
|
||||
{line.chapter && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{line.chapter}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = line.weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{lineTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = columnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(columnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.hasPhasing && viewMode === "by_chapter" && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
|
||||
Chapter
|
||||
</th>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
|
||||
>
|
||||
{week.label}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(data.chapterAggregation)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([chapter, weeklyHours]) => {
|
||||
const chapterTotal = Object.values(weeklyHours).reduce(
|
||||
(sum, h) => sum + h,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
key={chapter}
|
||||
className="border-b border-gray-100 hover:bg-gray-50/50"
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
|
||||
{chapter}
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const hours = weeklyHours[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className={clsx(
|
||||
"px-3 py-2 text-right tabular-nums",
|
||||
heatColor(hours, maxChapterHours),
|
||||
)}
|
||||
>
|
||||
{hours > 0 ? hours.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
|
||||
{chapterTotal.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
|
||||
Total
|
||||
</td>
|
||||
{data.weeks.map((week) => {
|
||||
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
|
||||
const total = chapterColumnTotals[key] ?? 0;
|
||||
return (
|
||||
<td
|
||||
key={key}
|
||||
className="px-3 py-3 text-right tabular-nums text-gray-900"
|
||||
>
|
||||
{total > 0 ? total.toFixed(1) : "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
|
||||
{Object.values(chapterColumnTotals)
|
||||
.reduce((sum, h) => sum + h, 0)
|
||||
.toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info about current phasing config */}
|
||||
{data?.hasPhasing && data.config && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Current phasing:</span>{" "}
|
||||
{data.config.pattern.replace("_", " ")} distribution from{" "}
|
||||
{data.config.startDate} to {data.config.endDate} across{" "}
|
||||
{data.weeks.length} weeks, {data.lines.length} demand lines.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { Suspense, useState } from "react";
|
||||
import { PreferencesModal } from "./PreferencesModal.js";
|
||||
import { ThemeProvider } from "./ThemeProvider.js";
|
||||
import { NotificationBell } from "../notifications/NotificationBell.js";
|
||||
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
|
||||
|
||||
const allNavItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
|
||||
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
];
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
|
||||
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
|
||||
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
|
||||
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
|
||||
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
|
||||
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
|
||||
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
|
||||
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
|
||||
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
|
||||
{ href: "/admin/users", label: "Users", icon: "👤" },
|
||||
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
|
||||
];
|
||||
|
||||
const managerNavItems = [
|
||||
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
|
||||
];
|
||||
|
||||
function Sidebar({ userRole }: { userRole: string }) {
|
||||
const pathname = usePathname();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
|
||||
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
|
||||
const showAdmin = userRole === "ADMIN";
|
||||
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
|
||||
Pl<span className="text-brand-600">anarchy</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{visibleNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{showManagerSection && (
|
||||
<>
|
||||
<div className="pt-3 pb-1">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{showAdmin ? "Admin" : "Management"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showAdmin && adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{managerNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href as Route}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-1">
|
||||
<NotificationBell />
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrefsOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Preferences
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{prefsOpen && <PreferencesModal onClose={() => setPrefsOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children, userRole = "USER" }: { children: React.ReactNode; userRole?: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Suspense>
|
||||
<NavProgressBar />
|
||||
</Suspense>
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<Sidebar userRole={userRole} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "~/hooks/useTheme.js";
|
||||
import type { AccentColor, ThemeMode } from "~/hooks/useTheme.js";
|
||||
import { useAppPreferences, type HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface PreferencesModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = [
|
||||
{ value: "sky", label: "Sky", swatch: "#0284c7" },
|
||||
{ value: "indigo", label: "Indigo", swatch: "#4f46e5" },
|
||||
{ value: "violet", label: "Violet", swatch: "#7c3aed" },
|
||||
{ value: "emerald", label: "Emerald", swatch: "#059669" },
|
||||
{ value: "rose", label: "Rose", swatch: "#e11d48" },
|
||||
{ value: "amber", label: "Amber", swatch: "#d97706" },
|
||||
];
|
||||
|
||||
export function PreferencesModal({ onClose }: PreferencesModalProps) {
|
||||
const { prefs, setMode, setAccent } = useTheme();
|
||||
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end sm:items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">Preferences</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-6">
|
||||
{/* Theme mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Appearance
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["light", "dark"] as ThemeMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setMode(mode)}
|
||||
className={clsx(
|
||||
"flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 text-sm font-medium transition-all",
|
||||
prefs.mode === mode
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{mode === "light" ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
Light
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
Dark
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent color */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Highlight Color
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{ACCENT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setAccent(opt.value)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
prefs.accent === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-gray-700"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: opt.swatch,
|
||||
boxShadow: prefs.accent === opt.value ? `0 0 0 2px white, 0 0 0 4px ${opt.swatch}` : "none",
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
{prefs.accent === opt.value && (
|
||||
<svg className="w-3 h-3 ml-auto text-brand-600 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M20.707 5.293a1 1 0 010 1.414l-11 11a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L9 15.586 19.293 5.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline defaults */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Timeline
|
||||
</label>
|
||||
|
||||
{/* Display mode */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Row display style</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: "strip", label: "Strips", desc: "Classic Gantt blocks" },
|
||||
{ value: "bar", label: "Bars", desc: "Daily stacked hours" },
|
||||
{ value: "heatmap", label: "Heatmap", desc: "Utilisation colours" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTimelineDisplayMode(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl border-2 text-xs font-medium transition-all",
|
||||
appPrefs.timelineDisplayMode === opt.value
|
||||
? "border-brand-600 bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600",
|
||||
)}
|
||||
>
|
||||
{/* Miniature icon */}
|
||||
{opt.value === "strip" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.6" />
|
||||
<rect x="18" y="4" width="12" height="8" rx="2" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : opt.value === "bar" ? (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="2" y="8" width="5" height="8" rx="1" fill="currentColor" opacity="0.6" />
|
||||
<rect x="9" y="4" width="5" height="12" rx="1" fill="currentColor" opacity="0.5" />
|
||||
<rect x="16" y="6" width="5" height="10" rx="1" fill="currentColor" opacity="0.7" />
|
||||
<rect x="23" y="2" width="5" height="14" rx="1" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="16" viewBox="0 0 32 16" className="opacity-70">
|
||||
<rect x="0" y="0" width="8" height="16" fill="#22c55e" opacity="0.5" />
|
||||
<rect x="8" y="0" width="8" height="16" fill="#eab308" opacity="0.5" />
|
||||
<rect x="16" y="0" width="8" height="16" fill="#f97316" opacity="0.5" />
|
||||
<rect x="24" y="0" width="8" height="16" fill="#ef4444" opacity="0.6" />
|
||||
<rect x="2" y="4" width="10" height="8" rx="1" fill="white" opacity="0.7" />
|
||||
<rect x="18" y="4" width="10" height="8" rx="1" fill="white" opacity="0.5" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{opt.label}</span>
|
||||
<span className="font-normal text-[10px] opacity-70">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Heatmap color scheme */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Heatmap color scale</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{
|
||||
value: "green-red" as HeatmapColorScheme,
|
||||
label: "Green → Red",
|
||||
stops: ["#22c55e","#84cc16","#facc15","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "blue-orange" as HeatmapColorScheme,
|
||||
label: "Blue → Orange",
|
||||
stops: ["#38bdf8","#3b82f6","#fbbf24","#f97316","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "purple-yellow" as HeatmapColorScheme,
|
||||
label: "Purple → Yellow",
|
||||
stops: ["#a78bfa","#8b5cf6","#facc15","#f59e0b","#ef4444"],
|
||||
},
|
||||
{
|
||||
value: "mono" as HeatmapColorScheme,
|
||||
label: "Monochrome",
|
||||
stops: ["#9ca3af","#6b7280","#4b5563","#374151","#111827"],
|
||||
},
|
||||
]).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setHeatmapColorScheme(opt.value)}
|
||||
className={clsx(
|
||||
"flex flex-col gap-1.5 px-2.5 py-2 rounded-xl border-2 text-xs font-medium transition-all text-left",
|
||||
appPrefs.heatmapColorScheme === opt.value
|
||||
? "border-brand-600 bg-brand-50 dark:bg-brand-900/30"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
{/* Gradient swatch */}
|
||||
<div className="flex rounded overflow-hidden w-full h-3">
|
||||
{opt.stops.map((c) => (
|
||||
<div key={c} className="flex-1" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-700 dark:text-gray-300">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<div className="relative mt-0.5 flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appPrefs.hideCompletedProjects}
|
||||
onChange={(e) => setHideCompletedProjects(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={clsx(
|
||||
"w-9 h-5 rounded-full transition-colors",
|
||||
appPrefs.hideCompletedProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
|
||||
)} />
|
||||
<div className={clsx(
|
||||
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
|
||||
appPrefs.hideCompletedProjects ? "translate-x-4" : "translate-x-0",
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
|
||||
Hide completed & cancelled projects
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Can be overridden per session in the timeline filter panel.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview note */}
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Changes apply instantly and are saved in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Applies the stored theme to <html> immediately on mount (client only).
|
||||
* Must be rendered inside the layout BEFORE the page content.
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("planarchy_theme");
|
||||
if (!raw) return;
|
||||
const prefs = JSON.parse(raw) as { mode?: string; accent?: string };
|
||||
const html = document.documentElement;
|
||||
if (prefs.mode === "dark") html.classList.add("dark");
|
||||
else html.classList.remove("dark");
|
||||
if (prefs.accent) html.setAttribute("data-accent", prefs.accent);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diff = now - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
||||
{ limit: 20 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const markRead = trpc.notification.markRead.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notification.unreadCount.invalidate();
|
||||
void utils.notification.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleMarkAllRead() {
|
||||
markRead.mutate({});
|
||||
}
|
||||
|
||||
function handleMarkOne(id: string) {
|
||||
markRead.mutate({ id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||
Notifications
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markRead.isPending}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
No notifications yet
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isUnread = n.readAt === null;
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) handleMarkOne(n.id);
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isUnread && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
<div className={isUnread ? "" : "ml-4"}>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||
{n.title}
|
||||
</p>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{n.body}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{relativeTime(new Date(n.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user