chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { BlueprintsClient } from "~/components/blueprints/BlueprintsClient.js";
|
||||
|
||||
export default function BlueprintsPage() {
|
||||
return <BlueprintsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ClientsAdminClient } from "~/components/admin/ClientsAdminClient.js";
|
||||
|
||||
export default function ClientsPage() {
|
||||
return <ClientsAdminClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CountriesClient } from "~/components/admin/CountriesClient.js";
|
||||
|
||||
export default function CountriesPage() {
|
||||
return <CountriesClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EffortRulesClient } from "~/components/admin/EffortRulesClient.js";
|
||||
|
||||
export default function EffortRulesPage() {
|
||||
return <EffortRulesClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExperienceMultipliersClient } from "~/components/admin/ExperienceMultipliersClient.js";
|
||||
|
||||
export default function ExperienceMultipliersPage() {
|
||||
return <ExperienceMultipliersClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ManagementLevelsClient } from "~/components/admin/ManagementLevelsClient.js";
|
||||
|
||||
export default function ManagementLevelsPage() {
|
||||
return <ManagementLevelsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrgUnitsClient } from "~/components/admin/OrgUnitsClient.js";
|
||||
|
||||
export default function OrgUnitsPage() {
|
||||
return <OrgUnitsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RateCardsClient } from "~/components/admin/RateCardsClient.js";
|
||||
|
||||
export default function RateCardsPage() {
|
||||
return <RateCardsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SystemSettingsClient } from "~/components/admin/SystemSettingsClient.js";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return <SystemSettingsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BatchSkillImport } from "~/components/admin/BatchSkillImport.js";
|
||||
|
||||
export default function BatchSkillImportPage() {
|
||||
return <BatchSkillImport />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UsersClient } from "~/components/admin/UsersClient.js";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <UsersClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UtilizationCategoriesClient } from "~/components/admin/UtilizationCategoriesClient.js";
|
||||
|
||||
export default function UtilizationCategoriesPage() {
|
||||
return <UtilizationCategoriesClient />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||
|
||||
export const metadata = { title: "Vacation Management — Planarchy" };
|
||||
|
||||
export default function AdminVacationsPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
|
||||
</div>
|
||||
<PublicHolidayBatch />
|
||||
<EntitlementManager />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export default function AllocationsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-36 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-32 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AllocationsClient } from "~/components/allocations/AllocationsClient.js";
|
||||
|
||||
export default function AllocationsPage() {
|
||||
return <AllocationsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SkillsAnalytics } from "~/components/analytics/SkillsAnalytics.js";
|
||||
|
||||
export default function SkillsAnalyticsPage() {
|
||||
return <SkillsAnalytics />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DashboardClient } from "~/components/dashboard/DashboardClient.js";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { AppRouter } from "@planarchy/api/router";
|
||||
import { EstimateStatus, type EstimateVersionStatus } from "@planarchy/shared";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { clsx } from "clsx";
|
||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
type EstimateListItem = RouterOutput["estimate"]["list"][number];
|
||||
type EstimateDetail = RouterOutput["estimate"]["getById"];
|
||||
|
||||
const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
DRAFT: "bg-slate-100 text-slate-700",
|
||||
IN_REVIEW: "bg-amber-100 text-amber-700",
|
||||
APPROVED: "bg-emerald-100 text-emerald-700",
|
||||
ARCHIVED: "bg-zinc-200 text-zinc-700",
|
||||
};
|
||||
|
||||
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
||||
WORKING: "bg-sky-100 text-sky-700",
|
||||
BASELINE: "bg-violet-100 text-violet-700",
|
||||
SUBMITTED: "bg-amber-100 text-amber-700",
|
||||
APPROVED: "bg-emerald-100 text-emerald-700",
|
||||
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
||||
};
|
||||
|
||||
function formatMoney(cents: number | null | undefined, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format((cents ?? 0) / 100);
|
||||
}
|
||||
|
||||
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
|
||||
if (metric.valueCents != null) {
|
||||
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||
}
|
||||
if (metric.key === "margin_percent") {
|
||||
return `${metric.valueDecimal.toFixed(0)}%`;
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
|
||||
}
|
||||
|
||||
function getLatestVersion(estimate: EstimateDetail | null | undefined) {
|
||||
if (!estimate) return null;
|
||||
return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null;
|
||||
}
|
||||
|
||||
function EstimateDetailPanel({
|
||||
estimate,
|
||||
onClone,
|
||||
cloning,
|
||||
}: {
|
||||
estimate: EstimateDetail;
|
||||
onClone?: (id: string) => void;
|
||||
cloning?: boolean;
|
||||
}) {
|
||||
const latestVersion = getLatestVersion(estimate);
|
||||
const latestMetrics = latestVersion?.metrics ?? [];
|
||||
|
||||
return (
|
||||
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Estimate detail</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-gray-900">{estimate.name}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}
|
||||
</p>
|
||||
</div>
|
||||
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
href={`/estimates/${estimate.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
{onClone && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={cloning}
|
||||
onClick={() => onClone(estimate.id)}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-300 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{cloning ? "Cloning..." : "Clone"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Version {latestVersion.versionNumber}
|
||||
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
|
||||
</span>
|
||||
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||
{latestVersion.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{latestMetrics.length > 0 && (
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{latestMetrics.map((metric) => (
|
||||
<div key={metric.id} className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900">{formatMetricValue(metric)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestVersion.notes && (
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
|
||||
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Scope items</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.scopeItems.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
No scope rows captured yet.
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.scopeItems.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-gray-900">{item.name}</p>
|
||||
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
||||
</div>
|
||||
{item.description && <p className="mt-1 text-sm text-gray-600">{item.description}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Demand lines</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{latestVersion.demandLines.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
|
||||
No staffing demand captured yet.
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.demandLines.map((line) => (
|
||||
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-gray-900">{line.name}</p>
|
||||
<p className="text-sm font-medium text-gray-600">{line.hours.toFixed(1)} h</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
|
||||
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
|
||||
{line.chapter && <span>{line.chapter}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
|
||||
No versions available for this estimate yet.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function EstimateCard({
|
||||
estimate,
|
||||
active,
|
||||
onSelect,
|
||||
canInspect,
|
||||
}: {
|
||||
estimate: EstimateListItem;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
canInspect: boolean;
|
||||
}) {
|
||||
const latestVersion = estimate.versions[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={!canInspect}
|
||||
className={clsx(
|
||||
"w-full rounded-3xl border p-5 text-left transition",
|
||||
active ? "border-brand-500 bg-brand-50 shadow-sm" : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm",
|
||||
!canInspect && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||
{estimate.project.shortCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900">{estimate.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{estimate.project ? estimate.project.name : "No linked project"}
|
||||
</p>
|
||||
</div>
|
||||
{latestVersion && (
|
||||
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
|
||||
<p className="mt-1 text-sm text-gray-700">{estimate.opportunityId ?? "Not set"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||
<p className="mt-1 text-sm text-gray-700">{formatDateLong(estimate.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canInspect && (
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
Detailed financial breakdown is limited to manager and controller roles.
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EstimatesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<EstimateStatus | "">("");
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [selectedEstimateId, setSelectedEstimateId] = useState<string | null>(null);
|
||||
const { canEdit, canViewCosts } = usePermissions();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const cloneMutation = trpc.estimate.clone.useMutation({
|
||||
onSuccess: (cloned) => {
|
||||
void utils.estimate.list.invalidate();
|
||||
setSelectedEstimateId(cloned.id);
|
||||
},
|
||||
});
|
||||
|
||||
const listQuery = trpc.estimate.list.useQuery(
|
||||
{
|
||||
query: search || undefined,
|
||||
status: status || undefined,
|
||||
},
|
||||
{ staleTime: 15_000 },
|
||||
);
|
||||
|
||||
const detailQuery = trpc.estimate.getById.useQuery(
|
||||
{ id: selectedEstimateId ?? "" },
|
||||
{
|
||||
enabled: canViewCosts && !!selectedEstimateId,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
|
||||
const estimates = listQuery.data ?? [];
|
||||
|
||||
const selectedEstimate = useMemo(() => {
|
||||
if (!canViewCosts) return null;
|
||||
return detailQuery.data ?? null;
|
||||
}, [canViewCosts, detailQuery.data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimating</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900">Browser-native estimate workspace</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-gray-600">
|
||||
Build structured estimates from live projects, resources, and role data instead of maintaining a disconnected spreadsheet.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||
>
|
||||
New Estimate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 lg:grid-cols-[minmax(0,1fr),220px]">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search by estimate or opportunity"
|
||||
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none ring-0 transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as EstimateStatus | "")}
|
||||
className="w-full rounded-2xl border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value.replace("_", " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Loading estimates...
|
||||
</div>
|
||||
) : estimates.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center">
|
||||
<p className="text-base font-medium text-gray-700">No estimates yet</p>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
Start with the wizard to create a connected estimate from Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(320px,0.95fr)]">
|
||||
<div className="space-y-4">
|
||||
{estimates.map((estimate) => (
|
||||
<EstimateCard
|
||||
key={estimate.id}
|
||||
estimate={estimate}
|
||||
active={estimate.id === selectedEstimateId}
|
||||
canInspect={canViewCosts}
|
||||
onSelect={() => {
|
||||
if (!canViewCosts) return;
|
||||
setSelectedEstimateId((current) => (current === estimate.id ? current : estimate.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{canViewCosts ? (
|
||||
selectedEstimate ? (
|
||||
<EstimateDetailPanel
|
||||
estimate={selectedEstimate}
|
||||
{...(canEdit ? { onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Select an estimate to inspect the current version, demand lines, and summary metrics.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-14 text-center text-sm text-gray-400">
|
||||
Your role can access the estimate list, but not the detailed financial breakdown.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wizardOpen && <EstimateWizard onClose={() => setWizardOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EstimateWorkspaceClient } from "~/components/estimates/EstimateWorkspaceClient.js";
|
||||
|
||||
interface EstimateWorkspacePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EstimateWorkspacePage({ params }: EstimateWorkspacePageProps) {
|
||||
const { id } = await params;
|
||||
return <EstimateWorkspaceClient estimateId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { EstimatesClient } from "./EstimatesClient.js";
|
||||
|
||||
export default function EstimatesPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<EstimatesClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AppShell } from "~/components/layout/AppShell.js";
|
||||
import { auth } from "~/server/auth.js";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
const userRole = (session.user as { role?: string }).role ?? "USER";
|
||||
return <AppShell userRole={userRole}>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function AppLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4 animate-pulse">
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-4 bg-gray-100 dark:bg-gray-800 rounded w-72" />
|
||||
<div className="grid grid-cols-4 gap-4 mt-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-64 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
<div className="h-48 bg-gray-100 dark:bg-gray-800 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import type { Project, ColumnDef } from "@planarchy/shared";
|
||||
import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared";
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ProjectModal } from "~/components/projects/ProjectModal.js";
|
||||
import { ProjectWizard } from "~/components/projects/ProjectWizard.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const ORDER_TYPE_COLORS: Record<string, string> = {
|
||||
BD: "bg-purple-100 text-purple-700",
|
||||
CHARGEABLE: "bg-green-100 text-green-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const ALL_STATUSES = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "ON_HOLD", label: "On Hold" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
] as const;
|
||||
|
||||
const ALL_ORDER_TYPES = [
|
||||
{ value: "BD", label: "BD" },
|
||||
{ value: "CHARGEABLE", label: "Chargeable" },
|
||||
{ value: "INTERNAL", label: "Internal" },
|
||||
{ value: "OVERHEAD", label: "Overhead" },
|
||||
] as const;
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: number; budgetCents: number }) {
|
||||
if (budgetCents === 0) {
|
||||
return <div className="text-xs text-gray-400">No budget</div>;
|
||||
}
|
||||
const cappedPercent = Math.min(utilizationPercent, 100);
|
||||
let barColor = "bg-green-500";
|
||||
if (utilizationPercent > 95) barColor = "bg-red-500";
|
||||
else if (utilizationPercent > 85) barColor = "bg-orange-500";
|
||||
else if (utilizationPercent > 70) barColor = "bg-yellow-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 min-w-[80px]">
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden w-full">
|
||||
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: ProjectRow; isOpen: boolean; onOpen: () => void; onClose: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateStatus = trpc.project.updateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); isOpen ? onClose() : onOpen(); }}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-80",
|
||||
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
|
||||
)}
|
||||
title="Click to change status"
|
||||
>
|
||||
{project.status}
|
||||
<svg className="w-2.5 h-2.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 top-full mt-1 z-20 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[130px]">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
disabled={s.value === project.status || updateStatus.isPending}
|
||||
onClick={(e) => { e.stopPropagation(); updateStatus.mutate({ id: project.id, status: s.value as never }); }}
|
||||
className={clsx(
|
||||
"w-full text-left px-3 py-1.5 text-xs transition-colors",
|
||||
s.value === project.status
|
||||
? "font-semibold text-gray-400 cursor-default"
|
||||
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
orderType: string;
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
budgetCents: number;
|
||||
winProbability: number;
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
utilizationPercent: number;
|
||||
dynamicFields?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function ProjectsClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [orderTypeFilter, setOrderTypeFilter] = useState<string>("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [openStatusProjectId, setOpenStatusProjectId] = useState<string | null>(null);
|
||||
const [batchStatusPicker, setBatchStatusPicker] = useState(false);
|
||||
const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null);
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewCosts } = usePermissions();
|
||||
|
||||
const batchUpdateStatus = trpc.project.batchUpdateStatus.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.project.listWithCosts.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Custom field columns from global blueprints ──────────────────────────
|
||||
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
|
||||
{ target: BlueprintTarget.PROJECT },
|
||||
{ staleTime: 300_000 },
|
||||
);
|
||||
const customColumns = useMemo<ColumnDef[]>(
|
||||
() =>
|
||||
(globalFieldDefs ?? [])
|
||||
.filter((f) => f.showInList)
|
||||
.map((f) => ({
|
||||
key: `custom_${f.key}`,
|
||||
label: f.label,
|
||||
defaultVisible: false,
|
||||
hideable: true,
|
||||
isCustom: true,
|
||||
fieldType: f.type as string,
|
||||
})),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
// Filter out budget column if user cannot view costs
|
||||
const baseColumns = useMemo<ColumnDef[]>(
|
||||
() => (canViewCosts ? PROJECT_COLUMNS : PROJECT_COLUMNS.filter((c) => c.key !== "budget")),
|
||||
[canViewCosts],
|
||||
);
|
||||
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||
"projects",
|
||||
baseColumns,
|
||||
customColumns,
|
||||
);
|
||||
const defaultKeys = useMemo(
|
||||
() => baseColumns.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||
[baseColumns],
|
||||
);
|
||||
|
||||
// ─── Infinite query (cursor-based) ────────────────────────────────────────
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = trpc.project.listWithCosts.useInfiniteQuery(
|
||||
{
|
||||
search: search || undefined,
|
||||
status: (statusFilter as ProjectStatus) || undefined,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev) => prev,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
|
||||
const allProjects = useMemo(
|
||||
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
|
||||
[data],
|
||||
);
|
||||
|
||||
// Client-side orderType filter
|
||||
const filteredProjects = useMemo(
|
||||
() => (orderTypeFilter ? allProjects.filter((p) => p.orderType === orderTypeFilter) : allProjects),
|
||||
[allProjects, orderTypeFilter],
|
||||
);
|
||||
|
||||
// ─── Sort + row order ─────────────────────────────────────────────────────
|
||||
const viewPrefs = useViewPrefs("projects");
|
||||
const { sorted, sortField, sortDir, toggle, reset } = useTableSort(filteredProjects, {
|
||||
initialField: viewPrefs.savedSort?.field ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: projects, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
const rowDragRef = useRef<string | null>(null);
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, statusFilter, orderTypeFilter]);
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function openNewModal() { setEditingProject(null); setModalOpen(true); }
|
||||
function openEditModal(project: Project) { setEditingProject(project); setModalOpen(true); }
|
||||
function closeModal() { setModalOpen(false); setEditingProject(null); }
|
||||
function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); }
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []),
|
||||
...(orderTypeFilter ? [{ label: `Type: ${orderTypeFilter}`, onRemove: () => setOrderTypeFilter("") }] : []),
|
||||
];
|
||||
|
||||
// ─── Cell renderer ────────────────────────────────────────────────────────
|
||||
function renderCell(col: ColumnDef, project: ProjectRow) {
|
||||
const dynFields = (project.dynamicFields ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (col.isCustom) {
|
||||
const fieldKey = col.key.replace(/^custom_/, "");
|
||||
const val = dynFields[fieldKey];
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{val != null ? String(val) : "—"}</td>;
|
||||
}
|
||||
|
||||
switch (col.key) {
|
||||
case "shortCode":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900">{project.shortCode}</td>;
|
||||
case "name":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 max-w-xs truncate">
|
||||
<Link href={`/projects/${project.id}`} className="hover:text-brand-600 hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
</td>
|
||||
);
|
||||
case "status":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<StatusDropdown
|
||||
project={project}
|
||||
isOpen={openStatusProjectId === project.id}
|
||||
onOpen={() => setOpenStatusProjectId(project.id)}
|
||||
onClose={() => setOpenStatusProjectId(null)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "orderType":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case "dates":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(project.startDate)} – {formatDate(project.endDate)}
|
||||
</td>
|
||||
);
|
||||
case "budget":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 min-w-[120px]">
|
||||
<div className="text-sm text-gray-900 mb-0.5">
|
||||
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
</td>
|
||||
);
|
||||
case "allocations":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
|
||||
</td>
|
||||
);
|
||||
case "responsible":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">—</td>;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Header renderer ──────────────────────────────────────────────────────
|
||||
const SORTABLE_PROJECT_COLS = new Set(["shortCode", "name", "status", "orderType", "dates", "budget", "allocations"]);
|
||||
function renderHeader(col: ColumnDef) {
|
||||
if (SORTABLE_PROJECT_COLS.has(col.key)) {
|
||||
return (
|
||||
<SortableColumnHeader
|
||||
key={col.key}
|
||||
label={col.label}
|
||||
field={col.key}
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={toggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
{hasNextPage ? "+" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
New Project Wizard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewModal}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={orderTypeFilter}
|
||||
onChange={(e) => setOrderTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{ALL_ORDER_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
{isCustomOrder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{/* Filter chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{/* Drag handle column */}
|
||||
<th className="w-8 px-2" />
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(projectIds)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
|
||||
}}
|
||||
onChange={() => selection.toggleAll(projectIds)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map(renderHeader)}
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{projects.map((project) => {
|
||||
const isSelected = selection.selectedIds.has(project.id);
|
||||
return (
|
||||
<DraggableTableRow
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => reorder(draggedId, project.id)}
|
||||
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.toggle(project.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</td>
|
||||
{visibleColumns.map((col) => renderCell(col, project))}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(project as unknown as Project)}
|
||||
className="text-xs text-gray-600 hover:text-gray-900 hover:underline font-medium transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
|
||||
View →
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</DraggableTableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No projects found.{" "}
|
||||
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
|
||||
Create your first project.
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScrollSentinel
|
||||
onVisible={handleFetchNext}
|
||||
isLoading={isFetchingNextPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
||||
<div className="bg-white rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Set status for {selection.count} projects</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmBatchStatus({ ids: selection.selectedArray, status: s.value });
|
||||
setBatchStatusPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
|
||||
{s.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm batch status change */}
|
||||
{confirmBatchStatus && (
|
||||
<ConfirmDialog
|
||||
title="Update Project Status"
|
||||
message={`Set ${confirmBatchStatus.ids.length} project${confirmBatchStatus.ids.length !== 1 ? "s" : ""} to "${confirmBatchStatus.status}"?`}
|
||||
confirmLabel="Update"
|
||||
onConfirm={() => {
|
||||
if (confirmBatchStatus) {
|
||||
batchUpdateStatus.mutate({ ids: confirmBatchStatus.ids, status: confirmBatchStatus.status as never });
|
||||
}
|
||||
setConfirmBatchStatus(null);
|
||||
}}
|
||||
onCancel={() => setConfirmBatchStatus(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
{ label: "Set Status…", onClick: () => setBatchStatusPicker(true) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && <ProjectModal project={editingProject} onClose={closeModal} />}
|
||||
|
||||
{/* Wizard */}
|
||||
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { formatDate } from "~/lib/format.js";
|
||||
import Link from "next/link";
|
||||
import { createCaller } from "~/server/trpc.js";
|
||||
import { BudgetStatusCard } from "~/components/projects/BudgetStatusCard.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
interface ProjectDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: "bg-gray-100 text-gray-700",
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||
COMPLETED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const ORDER_TYPE_COLORS: Record<string, string> = {
|
||||
BD: "bg-purple-100 text-purple-700",
|
||||
CHARGEABLE: "bg-green-100 text-green-700",
|
||||
INTERNAL: "bg-blue-100 text-blue-700",
|
||||
OVERHEAD: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
const ALLOC_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: "bg-green-100 text-green-700",
|
||||
PROPOSED: "bg-yellow-100 text-yellow-700",
|
||||
CONFIRMED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-gray-100 text-gray-500",
|
||||
};
|
||||
|
||||
export default async function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const trpc = await createCaller();
|
||||
|
||||
let project: Awaited<ReturnType<typeof trpc.project.getById>>;
|
||||
try {
|
||||
project = await trpc.project.getById({ id });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const activeAssignments = project.assignments.filter((assignment) => assignment.status !== "CANCELLED");
|
||||
const activeDemands = project.demands.filter((demand) => demand.status !== "CANCELLED");
|
||||
const requestedSeats = activeDemands.reduce((sum, demand) => sum + demand.requestedHeadcount, 0);
|
||||
const unfilledSeats = activeDemands.reduce((sum, demand) => sum + demand.unfilledHeadcount, 0);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Projects
|
||||
</Link>
|
||||
|
||||
{/* Project header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-mono text-sm font-medium text-gray-500">{project.shortCode}</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[project.status] ?? ""}`}>
|
||||
{project.status}
|
||||
</span>
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ORDER_TYPE_COLORS[project.orderType] ?? ""}`}>
|
||||
{project.orderType}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500 flex-shrink-0">
|
||||
<div className="font-medium text-gray-800">
|
||||
{formatDate(project.startDate)}
|
||||
{" — "}
|
||||
{formatDate(project.endDate)}
|
||||
</div>
|
||||
<div className="mt-0.5">Win probability: {project.winProbability}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4 mt-4 pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Chargecode</dt>
|
||||
<dd className="mt-0.5 text-sm font-mono font-medium text-gray-900">{project.shortCode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Order Type</dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{project.orderType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Allocation Type</dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{project.allocationType}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Assignments</dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">{activeAssignments.length} active</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Open Demands</dt>
|
||||
<dd className="mt-0.5 text-sm text-gray-900">
|
||||
{activeDemands.length} items · {unfilledSeats}/{requestedSeats} seats unfilled
|
||||
</dd>
|
||||
</div>
|
||||
{project.responsiblePerson && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-xs text-gray-500">Responsible Person</dt>
|
||||
<dd className="mt-0.5 text-sm font-medium text-gray-900">{project.responsiblePerson}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Budget status card (client component) */}
|
||||
<BudgetStatusCard projectId={project.id} />
|
||||
|
||||
{/* Assignments table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Assignments ({project.assignments.length})
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role <InfoTooltip content="Role this allocation was created for." />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Period <InfoTooltip content="Start and end date of the allocation." />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Hours/Day <InfoTooltip content="Planned working hours per calendar day." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Daily Cost <InfoTooltip content="Resource LCR × hours per day." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status <InfoTooltip content="PROPOSED = requested · CONFIRMED = approved · ACTIVE = ongoing · COMPLETED = finished · CANCELLED = removed." />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{project.assignments.map((assignment) => (
|
||||
<tr key={assignment.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{assignment.resource?.displayName ?? "—"}
|
||||
{assignment.resource?.eid && (
|
||||
<span className="ml-1.5 text-xs text-gray-400 font-mono">{assignment.resource.eid}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{assignment.role || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(assignment.startDate)}
|
||||
{" → "}
|
||||
{formatDate(assignment.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{assignment.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[assignment.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{assignment.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{project.assignments.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No assignments for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open demands table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Open Demands ({project.demands.length})
|
||||
</h2>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Period
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Requested
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Unfilled
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Hours/Day
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{project.demands.map((demand) => (
|
||||
<tr key={demand.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{formatDate(demand.startDate)}
|
||||
{" → "}
|
||||
{formatDate(demand.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.requestedHeadcount}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.unfilledHeadcount}</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900">{demand.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{demand.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{project.demands.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No open demands for this project.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export default function ProjectsLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-28 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-32 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="flex flex-col gap-1 w-24">
|
||||
<div className="h-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full" />
|
||||
<div className="h-2 w-10 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-8 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ProjectsClient } from "./ProjectsClient.js";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ProjectsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChargeabilityReportClient } from "~/components/reports/ChargeabilityReportClient.js";
|
||||
|
||||
export default function ChargeabilityReportPage() {
|
||||
return <ChargeabilityReportClient />;
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Resource, SkillEntry } from "@planarchy/shared";
|
||||
import { RESOURCE_COLUMNS } from "@planarchy/shared";
|
||||
import { BlueprintTarget } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { ResourceModal } from "~/components/resources/ResourceModal.js";
|
||||
import { ImportModal } from "~/components/resources/ImportModal.js";
|
||||
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
|
||||
import { useSelection } from "~/hooks/useSelection.js";
|
||||
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
|
||||
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { FilterBar } from "~/components/ui/FilterBar.js";
|
||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||
import { CustomFieldFilterBar } from "~/components/ui/CustomFieldFilterBar.js";
|
||||
import { useFilters } from "~/hooks/useFilters.js";
|
||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
|
||||
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
|
||||
type ModalState =
|
||||
| { type: "closed" }
|
||||
| { type: "create" }
|
||||
| { type: "edit"; resource: Resource }
|
||||
| { type: "import" }
|
||||
| { type: "bulkEdit" };
|
||||
|
||||
type ConfirmState =
|
||||
| { type: "closed" }
|
||||
| { type: "batchDeactivate"; ids: string[] }
|
||||
| { type: "deactivate"; resource: Resource };
|
||||
|
||||
type ActiveFilter = "active" | "inactive" | "all";
|
||||
type ResourceListPage = {
|
||||
resources: Resource[];
|
||||
total: number;
|
||||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
export function ResourcesClient() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [chapterFilter, setChapterFilter] = useState("");
|
||||
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active");
|
||||
const [modal, setModal] = useState<ModalState>({ type: "closed" });
|
||||
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
|
||||
|
||||
const selection = useSelection();
|
||||
const utils = trpc.useUtils();
|
||||
const { canViewScores, canViewCosts } = usePermissions();
|
||||
const { customFieldFilters, setCustomFieldFilter, clearFilters: clearCustomFilters } = useFilters();
|
||||
|
||||
// ─── Custom field columns from global blueprints ──────────────────────────
|
||||
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
|
||||
{ target: BlueprintTarget.RESOURCE },
|
||||
{ staleTime: 300_000 },
|
||||
);
|
||||
const customColumns = useMemo(
|
||||
() =>
|
||||
(globalFieldDefs ?? [])
|
||||
.filter((f) => f.showInList)
|
||||
.map((f) => ({
|
||||
key: `custom_${f.key}`,
|
||||
label: f.label,
|
||||
defaultVisible: false,
|
||||
hideable: true,
|
||||
isCustom: true,
|
||||
fieldType: f.type as string,
|
||||
})),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
const filterableFields = useMemo(
|
||||
() => (globalFieldDefs ?? []).filter((f) => f.isFilterable),
|
||||
[globalFieldDefs],
|
||||
);
|
||||
|
||||
// ─── Column visibility ────────────────────────────────────────────────────
|
||||
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
|
||||
"resources",
|
||||
RESOURCE_COLUMNS,
|
||||
customColumns,
|
||||
);
|
||||
const defaultKeys = useMemo(
|
||||
() => RESOURCE_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key),
|
||||
[],
|
||||
);
|
||||
|
||||
// ─── Infinite query (cursor-based) ────────────────────────────────────────
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
|
||||
} = (trpc.resource.list.useInfiniteQuery as any)(
|
||||
{
|
||||
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
|
||||
search: search || undefined,
|
||||
chapter: chapterFilter || undefined,
|
||||
includeRoles: true,
|
||||
limit: 50,
|
||||
...(customFieldFilters.length > 0 ? { customFieldFilters } : {}),
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage: ResourceListPage) => lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev: { pages: ResourceListPage[] } | undefined) => prev,
|
||||
staleTime: 20_000,
|
||||
},
|
||||
) as {
|
||||
data:
|
||||
| {
|
||||
pages: ResourceListPage[];
|
||||
}
|
||||
| undefined;
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
hasNextPage: boolean | undefined;
|
||||
};
|
||||
|
||||
const resources = useMemo(
|
||||
() => (data?.pages.flatMap((p) => p.resources) ?? []) as unknown as Resource[],
|
||||
[data],
|
||||
);
|
||||
const total = data?.pages[0]?.total ?? 0;
|
||||
|
||||
// ─── Sort + row order (per-user persistence) ──────────────────────────────
|
||||
const viewPrefs = useViewPrefs("resources");
|
||||
const { sorted, sortField, sortDir, toggle, reset } = useTableSort<Resource>(resources, {
|
||||
initialField: viewPrefs.savedSort?.field ?? null,
|
||||
initialDir: viewPrefs.savedSort?.dir ?? null,
|
||||
onSortChange: (field, dir) => {
|
||||
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
|
||||
},
|
||||
});
|
||||
const { orderedRows: displayedResources, reorder, isCustomOrder, resetOrder } = useRowOrder(
|
||||
sorted,
|
||||
viewPrefs,
|
||||
sortField,
|
||||
reset,
|
||||
);
|
||||
const rowDragRef = useRef<string | null>(null);
|
||||
const resourceIds: string[] = displayedResources.map((r) => r.id);
|
||||
|
||||
// Performance note: cursor-based infinite scroll (50 rows/page) keeps DOM nodes bounded.
|
||||
// True virtualizer is not needed for typical resource counts (<500).
|
||||
|
||||
// ─── Chargeability stats ──────────────────────────────────────────────────
|
||||
const { data: chargeabilityData } = trpc.resource.getChargeabilityStats.useQuery(
|
||||
{},
|
||||
{ enabled: canViewCosts, placeholderData: (prev) => prev, staleTime: 60_000 },
|
||||
);
|
||||
const chargeabilityMap = useMemo(
|
||||
() => new Map((chargeabilityData ?? []).map((s) => [s.id, s])),
|
||||
[chargeabilityData],
|
||||
);
|
||||
|
||||
// ─── Chapters filter ──────────────────────────────────────────────────────
|
||||
const { data: chapterData } = trpc.resource.chapters.useQuery(
|
||||
undefined,
|
||||
{ placeholderData: (prev) => prev, staleTime: 60_000 },
|
||||
);
|
||||
const chapters = chapterData ?? [];
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────
|
||||
const deactivateMutation = trpc.resource.deactivate.useMutation({
|
||||
onSuccess: async () => { await utils.resource.list.invalidate(); },
|
||||
});
|
||||
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.resource.list.invalidate();
|
||||
selection.clear();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
selection.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, chapterFilter, isActiveFilter]);
|
||||
|
||||
function closeModal() { setModal({ type: "closed" }); }
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirm.type === "deactivate") {
|
||||
deactivateMutation.mutate({ id: confirm.resource.id });
|
||||
} else if (confirm.type === "batchDeactivate") {
|
||||
batchDeactivateMutation.mutate({ ids: confirm.ids });
|
||||
}
|
||||
setConfirm({ type: "closed" });
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setSearch("");
|
||||
setChapterFilter("");
|
||||
setIsActiveFilter("active");
|
||||
clearCustomFilters();
|
||||
}
|
||||
|
||||
const handleFetchNext = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const chips = [
|
||||
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
|
||||
...(chapterFilter ? [{ label: `Chapter: ${chapterFilter}`, onRemove: () => setChapterFilter("") }] : []),
|
||||
...(isActiveFilter !== "active" ? [{ label: isActiveFilter === "all" ? "Showing all" : "Inactive only", onRemove: () => setIsActiveFilter("active") }] : []),
|
||||
...customFieldFilters.map((f) => ({
|
||||
label: `${f.key}: ${f.value}`,
|
||||
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resources</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">{total} resource{total !== 1 ? "s" : ""}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModal({ type: "import" })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModal({ type: "create" })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
New Resource
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters + Column toggle */}
|
||||
<FilterBar>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by name, EID, email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
|
||||
/>
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapterFilter}
|
||||
onChange={(e) => setChapterFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select
|
||||
value={isActiveFilter}
|
||||
onChange={(e) => setIsActiveFilter(e.target.value as ActiveFilter)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
|
||||
>
|
||||
<option value="active">Active only</option>
|
||||
<option value="inactive">Inactive only</option>
|
||||
<option value="all">All resources</option>
|
||||
</select>
|
||||
<ColumnTogglePanel
|
||||
allColumns={allColumns}
|
||||
visibleKeys={visibleKeys}
|
||||
onSetVisible={setVisible}
|
||||
defaultKeys={defaultKeys}
|
||||
/>
|
||||
{isCustomOrder && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
</button>
|
||||
)}
|
||||
</FilterBar>
|
||||
|
||||
{filterableFields.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<CustomFieldFilterBar
|
||||
filterableFields={filterableFields}
|
||||
activeFilters={customFieldFilters}
|
||||
onSetFilter={setCustomFieldFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chips.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<FilterChips chips={chips} onClearAll={clearAll} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
{isLoading && resources.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-400 text-sm animate-pulse">Loading resources…</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{/* Drag handle column */}
|
||||
<th className="w-8 px-2" />
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.isAllSelected(resourceIds)}
|
||||
ref={(el) => { if (el) el.indeterminate = selection.isIndeterminate(resourceIds); }}
|
||||
onChange={() => selection.toggleAll(resourceIds)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map((col) => {
|
||||
if (col.isCustom) {
|
||||
return (
|
||||
<th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
switch (col.key) {
|
||||
case "eid":
|
||||
return <SortableColumnHeader key={col.key} label="EID" field="eid" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Unique employee identifier used across all Planarchy records." />;
|
||||
case "displayName":
|
||||
return <SortableColumnHeader key={col.key} label="Name / Email" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
|
||||
case "chapter":
|
||||
return <SortableColumnHeader key={col.key} label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
|
||||
case "lcr":
|
||||
return <SortableColumnHeader key={col.key} label="LCR (€/h)" field="lcrCents" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Labour Cost Rate — the resource's hourly cost in EUR. Used to calculate project budgets (LCR × hours/day × working days)." />;
|
||||
case "chargeability":
|
||||
return <SortableColumnHeader key={col.key} label="Chargeability (actual)" field="chargeabilityTarget" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Actual = CONFIRMED+ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100. Expected (in parentheses) includes DRAFT projects. Target is the management-set goal." tooltipWidth="w-80" />;
|
||||
case "valueScore":
|
||||
return canViewScores
|
||||
? <SortableColumnHeader key={col.key} label="Score" field="valueScore" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Composite price/quality score 0–100. Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%. Recompute in Admin → Settings." tooltipWidth="w-72" />
|
||||
: null;
|
||||
case "roles":
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles <InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." /></th>;
|
||||
case "isActive":
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Skills <InfoTooltip content="Skills from the resource's skill matrix. Shows first 3; hover the +N badge for more." /></th>;
|
||||
default:
|
||||
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>;
|
||||
}
|
||||
})}
|
||||
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{displayedResources.map((resource) => {
|
||||
const skills = resource.skills as unknown as SkillEntry[];
|
||||
const isSelected = selection.selectedIds.has(resource.id);
|
||||
const isDeactivating =
|
||||
deactivateMutation.isPending &&
|
||||
(deactivateMutation.variables as { id: string } | undefined)?.id === resource.id;
|
||||
const dynFields = (resource as unknown as { dynamicFields?: Record<string, unknown> }).dynamicFields ?? {};
|
||||
|
||||
return (
|
||||
<DraggableTableRow
|
||||
key={resource.id}
|
||||
id={resource.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => reorder(draggedId, resource.id)}
|
||||
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" checked={isSelected} onChange={() => selection.toggle(resource.id)} className="rounded border-gray-300" />
|
||||
</td>
|
||||
{visibleColumns.map((col) => {
|
||||
if (col.isCustom) {
|
||||
const fieldKey = col.key.replace(/^custom_/, "");
|
||||
const val = dynFields[fieldKey];
|
||||
return <td key={col.key} className="px-3 py-3 text-sm text-gray-700">{val != null ? String(val) : "—"}</td>;
|
||||
}
|
||||
switch (col.key) {
|
||||
case "eid":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono text-gray-600">{resource.eid}</td>;
|
||||
case "displayName":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<Link href={`/resources/${resource.id}`} className="text-sm font-medium text-gray-900 hover:text-brand-600 hover:underline transition-colors">{resource.displayName}</Link>
|
||||
<div className="text-xs text-gray-500">{resource.email}</div>
|
||||
</td>
|
||||
);
|
||||
case "chapter":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{resource.chapter ?? "—"}</td>;
|
||||
case "lcr":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900">{(resource.lcrCents / 100).toFixed(0)} {resource.currency}</td>;
|
||||
case "chargeability": {
|
||||
if (!canViewCosts) return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">{resource.chargeabilityTarget}%</td>;
|
||||
const stats = chargeabilityMap.get(resource.id);
|
||||
const actual = stats?.actualChargeability;
|
||||
const expected = stats?.expectedChargeability;
|
||||
const target = resource.chargeabilityTarget;
|
||||
const color = actual == null ? "text-gray-400" : actual >= target ? "text-green-700" : actual >= target - 20 ? "text-amber-600" : "text-red-600";
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
<div>
|
||||
<span className={`font-medium ${color}`}>{actual != null ? `${actual}%` : "—"}</span>
|
||||
{expected != null && expected !== actual && <span className="text-xs text-gray-400 ml-1">({expected}% exp.)</span>}
|
||||
<div className="text-xs text-gray-400">Target: {target}%</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "valueScore": {
|
||||
if (!canViewScores) return null;
|
||||
const score = (resource as unknown as { valueScore?: number | null }).valueScore;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm">
|
||||
{score != null ? (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700" : score >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>{score}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "roles": {
|
||||
const rr = ((resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[] }).resourceRoles ?? []);
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rr.map((r) => (
|
||||
<span key={r.role.id} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full font-medium" style={{ backgroundColor: `${r.role.color ?? "#6366f1"}22`, color: r.role.color ?? "#6366f1" }}>
|
||||
{r.isPrimary && <span className="text-[10px]">★</span>}
|
||||
{r.role.name}
|
||||
</span>
|
||||
))}
|
||||
{rr.length === 0 && <span className="text-xs text-gray-400">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case "isActive":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skills.slice(0, 3).map((s) => (
|
||||
<span key={s.skill} className="inline-block px-2 py-0.5 text-xs bg-brand-50 text-brand-700 rounded-full">{s.skill}</span>
|
||||
))}
|
||||
{skills.length > 3 && <span className="text-xs text-gray-400">+{skills.length - 3}</span>}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">—</td>;
|
||||
}
|
||||
})}
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button type="button" onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource })} className="text-xs font-medium text-brand-600 hover:text-brand-800 transition-colors mr-3">Edit</button>
|
||||
<button type="button" onClick={() => setConfirm({ type: "deactivate", resource: resource as unknown as Resource })} disabled={isDeactivating} className="text-xs font-medium text-red-600 hover:text-red-800 transition-colors disabled:opacity-50">{isDeactivating ? "Deactivating…" : "Deactivate"}</button>
|
||||
</td>
|
||||
</DraggableTableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{displayedResources.length === 0 && !isLoading && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No resources found.</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BatchActionBar
|
||||
count={selection.count}
|
||||
onClear={selection.clear}
|
||||
actions={[
|
||||
...(filterableFields.length > 0 ? [{
|
||||
label: "Edit Custom Fields",
|
||||
variant: "default" as const,
|
||||
onClick: () => setModal({ type: "bulkEdit" }),
|
||||
disabled: false,
|
||||
}] : []),
|
||||
{
|
||||
label: `Deactivate ${selection.count > 0 ? `(${selection.count})` : ""}`,
|
||||
variant: "danger" as const,
|
||||
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
|
||||
disabled: batchDeactivateMutation.isPending,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
|
||||
{modal.type === "edit" && <ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />}
|
||||
{modal.type === "import" && <ImportModal onClose={closeModal} />}
|
||||
{modal.type === "bulkEdit" && (
|
||||
<BulkEditModal
|
||||
selectedIds={selection.selectedArray}
|
||||
fieldDefs={filterableFields}
|
||||
onClose={closeModal}
|
||||
onSuccess={selection.clear}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm.type === "deactivate" && (
|
||||
<ConfirmDialog title="Deactivate Resource" message={`Deactivate "${confirm.resource.displayName}" (${confirm.resource.eid})? This will remove them from the active resource list.`} confirmLabel="Deactivate" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
|
||||
)}
|
||||
{confirm.type === "batchDeactivate" && (
|
||||
<ConfirmDialog title="Deactivate Resources" message={`Deactivate ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""}?`} confirmLabel="Deactivate All" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { createCaller } from "~/server/trpc.js";
|
||||
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const trpc = await createCaller();
|
||||
const resource = await trpc.resource.getById({ id });
|
||||
return { title: `${resource.displayName} — Resources | Planarchy` };
|
||||
} catch {
|
||||
return { title: "Resource — Planarchy" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ResourceDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <ResourceDetail resourceId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export default function ResourcesLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 animate-pulse">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-7 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-12 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="flex gap-1 flex-1">
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
|
||||
</div>
|
||||
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { ResourcesClient } from "./ResourcesClient.js";
|
||||
|
||||
export default function ResourcesPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResourcesClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RolesClient } from "~/components/roles/RolesClient.js";
|
||||
|
||||
export default function RolesPage() {
|
||||
return <RolesClient />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StaffingPanel } from "~/components/staffing/StaffingPanel.js";
|
||||
|
||||
export default function StaffingPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Staffing Suggestions</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Find the best resource match for your project needs</p>
|
||||
</div>
|
||||
<StaffingPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export default function TimelineLoading() {
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-0 animate-pulse">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
<div className="flex-1" />
|
||||
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-8 w-8 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
<div className="h-8 w-20 bg-gray-100 dark:bg-gray-800 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Date header */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="w-48 flex-shrink-0 px-4 py-2">
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="flex-1 flex gap-px py-2 px-2">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div key={i} className="flex-1 h-3 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource rows */}
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex border-b border-gray-100 dark:border-gray-800 py-3">
|
||||
{/* Resource name cell */}
|
||||
<div className="w-48 flex-shrink-0 px-4 flex flex-col gap-1.5">
|
||||
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-2 w-12 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||
</div>
|
||||
{/* Allocation bars */}
|
||||
<div className="flex-1 relative px-2 flex items-center gap-1">
|
||||
{i % 3 === 0 && (
|
||||
<div className="h-7 rounded-lg bg-brand-100 dark:bg-brand-900/30" style={{ width: "35%", marginLeft: "10%" }} />
|
||||
)}
|
||||
{i % 3 === 1 && (
|
||||
<>
|
||||
<div className="h-7 rounded-lg bg-purple-100 dark:bg-purple-900/30" style={{ width: "20%", marginLeft: "5%" }} />
|
||||
<div className="h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30" style={{ width: "30%", marginLeft: "2%" }} />
|
||||
</>
|
||||
)}
|
||||
{i % 3 === 2 && (
|
||||
<div className="h-7 rounded-lg bg-green-100 dark:bg-green-900/30" style={{ width: "45%", marginLeft: "20%" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { TimelineView } from "~/components/timeline/TimelineView.js";
|
||||
|
||||
export default function TimelinePage() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-6 pb-0">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Timeline</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Interactive resource planning timeline</p>
|
||||
</div>
|
||||
<TimelineView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MyVacationsClient } from "~/components/vacations/MyVacationsClient.js";
|
||||
|
||||
export const metadata = { title: "My Vacations — Planarchy" };
|
||||
|
||||
export default function MyVacationsPage() {
|
||||
return <MyVacationsClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VacationClient } from "~/components/vacations/VacationClient.js";
|
||||
|
||||
export default function VacationsPage() {
|
||||
return <VacationClient />;
|
||||
}
|
||||
Reference in New Issue
Block a user