401 lines
16 KiB
TypeScript
401 lines
16 KiB
TypeScript
"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)} />}
|
|
</>
|
|
);
|
|
}
|