feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -2,18 +2,71 @@
|
||||
|
||||
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, formatMoney } 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"];
|
||||
type EstimateMetric = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueDecimal: number;
|
||||
valueCents: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
|
||||
type EstimateScopeItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
scopeType: string;
|
||||
};
|
||||
|
||||
type EstimateDemandLine = {
|
||||
id: string;
|
||||
name: string;
|
||||
hours: number;
|
||||
costTotalCents: number;
|
||||
priceTotalCents: number;
|
||||
currency: string;
|
||||
chapter: string | null;
|
||||
};
|
||||
|
||||
type EstimateVersion = {
|
||||
versionNumber: number;
|
||||
label: string | null;
|
||||
status: EstimateVersionStatus;
|
||||
notes: string | null;
|
||||
metrics: EstimateMetric[];
|
||||
scopeItems: EstimateScopeItem[];
|
||||
demandLines: EstimateDemandLine[];
|
||||
};
|
||||
|
||||
type EstimateProjectRef = {
|
||||
shortCode: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EstimateListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EstimateStatus;
|
||||
opportunityId: string | null;
|
||||
updatedAt: Date | string;
|
||||
project: EstimateProjectRef | null;
|
||||
versions: Array<Pick<EstimateVersion, "versionNumber" | "status">>;
|
||||
};
|
||||
|
||||
type EstimateDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: EstimateStatus;
|
||||
project: EstimateProjectRef | null;
|
||||
versions: EstimateVersion[];
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
DRAFT: "bg-slate-100 text-slate-700",
|
||||
@@ -30,7 +83,7 @@ const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
||||
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
||||
};
|
||||
|
||||
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
|
||||
function formatMetricValue(metric: EstimateMetric) {
|
||||
if (metric.valueCents != null) {
|
||||
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||
}
|
||||
@@ -42,7 +95,13 @@ function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"]
|
||||
|
||||
function getLatestVersion(estimate: EstimateDetail | null | undefined) {
|
||||
if (!estimate) return null;
|
||||
return [...estimate.versions].sort((left, right) => right.versionNumber - left.versionNumber)[0] ?? null;
|
||||
let latest = estimate.versions[0] ?? null;
|
||||
for (const version of estimate.versions) {
|
||||
if (!latest || version.versionNumber > latest.versionNumber) {
|
||||
latest = version;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function EstimateDetailPanel({
|
||||
@@ -58,16 +117,27 @@ function EstimateDetailPanel({
|
||||
const latestMetrics = latestVersion?.metrics ?? [];
|
||||
|
||||
return (
|
||||
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<aside className="app-surface h-full p-5">
|
||||
<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 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 dark:text-gray-50">
|
||||
{estimate.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{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])}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-1 text-xs font-semibold",
|
||||
STATUS_STYLES[estimate.status],
|
||||
)}
|
||||
>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,7 +154,7 @@ function EstimateDetailPanel({
|
||||
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"
|
||||
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 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
{cloning ? "Cloning..." : "Clone"}
|
||||
</button>
|
||||
@@ -98,7 +168,12 @@ function EstimateDetailPanel({
|
||||
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])}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
VERSION_STYLES[latestVersion.status],
|
||||
)}
|
||||
>
|
||||
{latestVersion.status}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,25 +181,32 @@ function EstimateDetailPanel({
|
||||
{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">
|
||||
<div
|
||||
key={metric.id}
|
||||
className="rounded-2xl border border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/70"
|
||||
>
|
||||
<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>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatMetricValue(metric)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{latestVersion.notes && (
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<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>
|
||||
<p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{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>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Scope items
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.scopeItems.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -134,12 +216,19 @@ function EstimateDetailPanel({
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.scopeItems.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium text-gray-900">{item.name}</p>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{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>}
|
||||
{item.description && (
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -148,7 +237,9 @@ function EstimateDetailPanel({
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Demand lines</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Demand lines
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{latestVersion.demandLines.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -158,12 +249,17 @@ function EstimateDetailPanel({
|
||||
</p>
|
||||
) : (
|
||||
latestVersion.demandLines.map((line) => (
|
||||
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||
<div
|
||||
key={line.id}
|
||||
className="rounded-2xl border border-gray-100 px-4 py-3 dark:border-gray-800"
|
||||
>
|
||||
<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>
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{line.hours.toFixed(1)} h
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatMoney(line.costTotalCents, line.currency)} cost</span>
|
||||
<span>{formatMoney(line.priceTotalCents, line.currency)} sell</span>
|
||||
{line.chapter && <span>{line.chapter}</span>}
|
||||
@@ -204,14 +300,21 @@ function EstimateCard({
|
||||
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",
|
||||
active
|
||||
? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700",
|
||||
!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])}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||
STATUS_STYLES[estimate.status],
|
||||
)}
|
||||
>
|
||||
{estimate.status.replace("_", " ")}
|
||||
</span>
|
||||
{estimate.project && (
|
||||
@@ -220,13 +323,20 @@ function EstimateCard({
|
||||
</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">
|
||||
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
{estimate.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{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])}>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
VERSION_STYLES[latestVersion.status],
|
||||
)}
|
||||
>
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
)}
|
||||
@@ -235,16 +345,20 @@ function EstimateCard({
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
|
||||
<p className="mt-1 text-sm text-gray-700">{estimate.opportunityId ?? "Not set"}</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{estimate.opportunityId ?? "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||
<p className="mt-1 text-sm text-gray-700">{formatDateLong(estimate.updatedAt)}</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
{formatDateLong(estimate.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canInspect && (
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
Detailed financial breakdown is limited to manager and controller roles.
|
||||
</p>
|
||||
)}
|
||||
@@ -283,23 +397,28 @@ export function EstimatesClient() {
|
||||
},
|
||||
);
|
||||
|
||||
const estimates = listQuery.data ?? [];
|
||||
const estimates = (listQuery.data ?? []) as unknown as EstimateListItem[];
|
||||
|
||||
const selectedEstimate = useMemo(() => {
|
||||
if (!canViewCosts) return null;
|
||||
return detailQuery.data ?? null;
|
||||
return (detailQuery.data ?? null) as unknown as EstimateDetail | 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="app-page space-y-6">
|
||||
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
|
||||
<div className="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 className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
|
||||
Estimating
|
||||
</p>
|
||||
<h1 className="mt-2 font-display text-3xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
Browser-native estimate workspace
|
||||
</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
||||
Build structured estimates from live projects, resources, and role data instead of
|
||||
maintaining a disconnected spreadsheet.
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
@@ -319,12 +438,12 @@ export function EstimatesClient() {
|
||||
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"
|
||||
className="app-input rounded-2xl px-4 py-3"
|
||||
/>
|
||||
<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"
|
||||
className="app-select w-full rounded-2xl px-4 py-3"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{Object.values(EstimateStatus).map((value) => (
|
||||
@@ -337,13 +456,15 @@ export function EstimatesClient() {
|
||||
</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">
|
||||
<div className="app-surface-strong border-dashed 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">
|
||||
<div className="app-surface-strong border-dashed px-6 py-14 text-center">
|
||||
<p className="text-base font-medium text-gray-700 dark:text-gray-100">
|
||||
No estimates yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
|
||||
Start with the wizard to create a connected estimate from Planarchy data.
|
||||
</p>
|
||||
</div>
|
||||
@@ -358,7 +479,9 @@ export function EstimatesClient() {
|
||||
canInspect={canViewCosts}
|
||||
onSelect={() => {
|
||||
if (!canViewCosts) return;
|
||||
setSelectedEstimateId((current) => (current === estimate.id ? current : estimate.id));
|
||||
setSelectedEstimateId((current) =>
|
||||
current === estimate.id ? current : estimate.id,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -369,15 +492,21 @@ export function EstimatesClient() {
|
||||
selectedEstimate ? (
|
||||
<EstimateDetailPanel
|
||||
estimate={selectedEstimate}
|
||||
{...(canEdit ? { onClone: (id: string) => cloneMutation.mutate({ sourceEstimateId: id }), cloning: cloneMutation.isPending } : {})}
|
||||
{...(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 className="app-surface-strong border-dashed 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">
|
||||
<div className="app-surface-strong border-dashed 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>
|
||||
)}
|
||||
|
||||
@@ -69,8 +69,8 @@ function BudgetBar({ utilizationPercent, budgetCents }: { utilizationPercent: nu
|
||||
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="min-w-[104px] space-y-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200/80 dark:bg-gray-700/80">
|
||||
<div className={clsx("h-full rounded-full transition-all", barColor)} style={{ width: `${cappedPercent}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{utilizationPercent.toFixed(0)}% used</div>
|
||||
@@ -104,7 +104,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
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",
|
||||
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||
STATUS_COLORS[project.status] ?? "bg-gray-100 text-gray-700",
|
||||
)}
|
||||
title="Click to change status"
|
||||
@@ -115,7 +115,7 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
</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]">
|
||||
<div className="absolute left-0 top-full z-20 mt-2 min-w-[160px] rounded-2xl border border-gray-200 bg-white p-2 shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
@@ -123,10 +123,10 @@ function StatusDropdown({ project, isOpen, onOpen, onClose }: { project: Project
|
||||
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",
|
||||
"w-full rounded-xl px-3 py-2 text-left text-xs transition",
|
||||
s.value === project.status
|
||||
? "font-semibold text-gray-400 cursor-default"
|
||||
: "text-gray-700 hover:bg-gray-50 cursor-pointer",
|
||||
? "cursor-default font-semibold text-gray-400"
|
||||
: "cursor-pointer text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
<span className={clsx("inline-block px-1.5 py-0.5 rounded-full", STATUS_COLORS[s.value] ?? "bg-gray-100 text-gray-700")}>
|
||||
@@ -226,19 +226,30 @@ export function ProjectsClient() {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = trpc.project.listWithCosts.useInfiniteQuery(
|
||||
// Keep this boundary shallow; full TRPC inference here can trip TS depth limits.
|
||||
} = (trpc.project.listWithCosts.useInfiniteQuery as any)(
|
||||
{
|
||||
search: search || undefined,
|
||||
status: (statusFilter as ProjectStatus) || undefined,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
getNextPageParam: (lastPage: { nextCursor?: string | null }) => lastPage.nextCursor ?? undefined,
|
||||
initialCursor: undefined,
|
||||
placeholderData: (prev) => prev,
|
||||
placeholderData: (prev: { pages: { projects: ProjectRow[]; nextCursor?: string | null }[] } | undefined) => prev,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
) as {
|
||||
data:
|
||||
| {
|
||||
pages: { projects: ProjectRow[]; nextCursor?: string | null }[];
|
||||
}
|
||||
| undefined;
|
||||
isLoading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
hasNextPage: boolean | undefined;
|
||||
};
|
||||
|
||||
const allProjects = useMemo(
|
||||
() => (data?.pages.flatMap((p) => p.projects) ?? []) as unknown as ProjectRow[],
|
||||
@@ -297,16 +308,16 @@ export function ProjectsClient() {
|
||||
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>;
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{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>;
|
||||
return <td key={col.key} className="px-4 py-3 text-sm font-mono font-medium text-gray-900 dark:text-gray-100">{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">
|
||||
<td key={col.key} className="max-w-xs truncate px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<Link href={`/projects/${project.id}`} className="transition hover:text-brand-600 hover:underline">
|
||||
{project.name}
|
||||
</Link>
|
||||
</td>
|
||||
@@ -332,14 +343,14 @@ export function ProjectsClient() {
|
||||
);
|
||||
case "dates":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
|
||||
<td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{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">
|
||||
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
||||
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
@@ -347,14 +358,14 @@ export function ProjectsClient() {
|
||||
);
|
||||
case "allocations":
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 text-right">
|
||||
<td key={col.key} className="px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-300">
|
||||
{project.totalPersonDays > 0 ? `${project.totalPersonDays}d` : "—"}
|
||||
</td>
|
||||
);
|
||||
case "responsible":
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
||||
default:
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">—</td>;
|
||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">—</td>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,20 +385,19 @@ export function ProjectsClient() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th key={col.key} className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="app-page space-y-5">
|
||||
<div className="app-page-header gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
||||
<h1 className="app-page-title">Projects</h1>
|
||||
{!isLoading && (
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
<p className="app-page-subtitle mt-1">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
{hasNextPage ? "+" : ""}
|
||||
</p>
|
||||
@@ -397,7 +407,7 @@ export function ProjectsClient() {
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||
>
|
||||
<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" />
|
||||
@@ -407,7 +417,7 @@ export function ProjectsClient() {
|
||||
<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"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
<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" />
|
||||
@@ -417,19 +427,18 @@ export function ProjectsClient() {
|
||||
</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"
|
||||
className="app-input max-w-xs"
|
||||
/>
|
||||
<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"
|
||||
className="app-select"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
@@ -439,7 +448,7 @@ export function ProjectsClient() {
|
||||
<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"
|
||||
className="app-select"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{ALL_ORDER_TYPES.map((t) => (
|
||||
@@ -456,7 +465,7 @@ export function ProjectsClient() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOrder}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
|
||||
className="whitespace-nowrap text-xs text-gray-500 underline transition hover:text-gray-700 dark:hover:text-gray-200"
|
||||
title="Clear manual row order"
|
||||
>
|
||||
Reset order
|
||||
@@ -464,22 +473,20 @@ export function ProjectsClient() {
|
||||
)}
|
||||
</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">
|
||||
<div className="app-data-table">
|
||||
{isLoading ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400 animate-pulse">Loading projects…</div>
|
||||
<div className="py-16 text-center text-sm text-gray-500 animate-pulse">Loading projects…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<thead className="border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
{/* Drag handle column */}
|
||||
<th className="w-8 px-2" />
|
||||
@@ -491,7 +498,7 @@ export function ProjectsClient() {
|
||||
if (el) el.indeterminate = selection.isIndeterminate(projectIds);
|
||||
}}
|
||||
onChange={() => selection.toggleAll(projectIds)}
|
||||
className="rounded border-gray-300"
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
{visibleColumns.map(renderHeader)}
|
||||
@@ -500,7 +507,7 @@ export function ProjectsClient() {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{projects.map((project) => {
|
||||
const isSelected = selection.selectedIds.has(project.id);
|
||||
return (
|
||||
@@ -509,14 +516,14 @@ export function ProjectsClient() {
|
||||
id={project.id}
|
||||
dragRef={rowDragRef}
|
||||
onDrop={(draggedId) => reorder(draggedId, project.id)}
|
||||
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
|
||||
className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => selection.toggle(project.id)}
|
||||
className="rounded border-gray-300"
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
{visibleColumns.map((col) => renderCell(col, project))}
|
||||
@@ -525,11 +532,11 @@ export function ProjectsClient() {
|
||||
<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"
|
||||
className="text-xs font-medium text-gray-600 transition-colors hover:text-gray-900 hover:underline dark:text-gray-300 dark:hover:text-gray-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<Link href={`/projects/${project.id}`} className="text-xs text-blue-600 hover:text-blue-800 hover:underline font-medium">
|
||||
<Link href={`/projects/${project.id}`} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">
|
||||
View →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -542,7 +549,7 @@ export function ProjectsClient() {
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="py-14 text-center text-sm text-gray-500">
|
||||
No projects found.{" "}
|
||||
<button type="button" onClick={openNewModal} className="text-brand-600 hover:underline font-medium">
|
||||
Create your first project.
|
||||
@@ -561,8 +568,8 @@ export function ProjectsClient() {
|
||||
{/* 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="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} projects</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
@@ -572,7 +579,7 @@ export function ProjectsClient() {
|
||||
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"
|
||||
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className={clsx("inline-block px-2 py-0.5 text-xs rounded-full", STATUS_COLORS[s.value])}>
|
||||
{s.label}
|
||||
@@ -614,6 +621,6 @@ export function ProjectsClient() {
|
||||
|
||||
{/* Wizard */}
|
||||
<ProjectWizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { ProjectsClient } from "./ProjectsClient.js";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ProjectsClient />
|
||||
</div>
|
||||
);
|
||||
return <ProjectsClient />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,5 @@
|
||||
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>
|
||||
);
|
||||
return <StaffingPanel />;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ 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 className="app-page flex h-full flex-col gap-5 pb-6">
|
||||
<div className="app-page-header">
|
||||
<div>
|
||||
<h1 className="app-page-title">Timeline</h1>
|
||||
<p className="app-page-subtitle mt-1">Interactive resource planning timeline</p>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineView />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user