chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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>
);
}
+14
View File
@@ -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>;
}
+15
View File
@@ -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>
);
}
+9
View File
@@ -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 0100. 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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { ResourcesClient } from "./ResourcesClient.js";
export default function ResourcesPage() {
return (
<Suspense>
<ResourcesClient />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { RolesClient } from "~/components/roles/RolesClient.js";
export default function RolesPage() {
return <RolesClient />;
}
+13
View File
@@ -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>
);
}
+13
View File
@@ -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",
},
});
}
+35
View File
@@ -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 };
+101
View File
@@ -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>
);
}
+265
View File
@@ -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;
}
}
+27
View File
@@ -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>
);
}
+12
View File
@@ -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");
}