feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+390 -11
View File
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment";
type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
@@ -17,10 +17,50 @@ interface FilterRow {
value: string;
}
interface AvailableColumn {
key: string;
label: string;
dataType: "string" | "number" | "date" | "boolean";
}
interface TemplateConfig {
entity: EntityType;
columns: string[];
filters: Omit<FilterRow, "id">[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
periodMonth?: string;
}
interface ReportTemplateSummary {
id: string;
name: string;
description?: string | null;
entity: EntityType;
config: TemplateConfig;
isShared: boolean;
isOwner: boolean;
updatedAt: string | Date;
}
interface ReportBlueprint {
id: string;
label: string;
description: string;
entity: EntityType;
columns: string[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
templateName: string;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
{ value: "resource_month", label: "Resource Months" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
sortBy: "displayName",
sortDir: "asc",
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
columns: [
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
];
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
function getCurrentPeriodMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
@@ -50,6 +200,9 @@ export function ReportBuilder() {
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateName, setTemplateName] = useState<string>("");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
@@ -59,7 +212,21 @@ export function ReportBuilder() {
{ placeholderData: keepPreviousData },
);
const availableColumns = columnsQuery.data ?? [];
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
const templatesQuery = trpc.report.listTemplates.useQuery();
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
onSuccess: async (result) => {
setSelectedTemplateId(result.id);
await templatesQuery.refetch();
},
});
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
onSuccess: async () => {
setSelectedTemplateId("");
setTemplateName("");
await templatesQuery.refetch();
},
});
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
@@ -76,12 +243,13 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
@@ -99,6 +267,40 @@ export function ReportBuilder() {
setFilters([]);
setGroupBy("");
setSortBy("");
if (newEntity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
}, [templatesQuery.data]);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
setSelectedTemplateId("");
setTemplateName(blueprint.templateName);
setEntity(blueprint.entity);
setSelectedColumns(new Set(blueprint.columns));
setFilters([]);
setGroupBy(blueprint.groupBy ?? "");
setSortBy(blueprint.sortBy ?? "");
setSortDir(blueprint.sortDir ?? "asc");
if (blueprint.entity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
@@ -163,6 +365,7 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
@@ -179,7 +382,42 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
await saveTemplateMutation.mutateAsync({
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
name: templateName.trim(),
config: {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
},
});
}, [
entity,
filters,
groupBy,
periodMonth,
saveTemplateMutation,
selectedColumns,
selectedTemplateId,
sortBy,
sortDir,
templateName,
]);
const handleDeleteTemplate = useCallback(async () => {
if (!selectedTemplateId) return;
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
}, [deleteTemplateMutation, selectedTemplateId]);
// ─── Derived ──────────────────────────────────────────────────────────
@@ -188,6 +426,15 @@ export function ReportBuilder() {
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
const templates = templatesQuery.data ?? [];
const resourceMonthBlueprints = useMemo(
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
[entity],
);
const recommendedColumnSet = useMemo(
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
[entity],
);
// Column label lookup
const columnLabelMap = useMemo(() => {
@@ -212,6 +459,61 @@ export function ReportBuilder() {
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template
</label>
<select
value={selectedTemplateId}
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
if (template) {
applyTemplate(template);
}
}}
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
>
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template name
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="Monthly SAH by location"
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<button
type="button"
onClick={() => void handleSaveTemplate()}
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
</button>
<button
type="button"
onClick={() => void handleDeleteTemplate()}
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -234,6 +536,73 @@ export function ReportBuilder() {
</button>
))}
</div>
{entity === "resource_month" && (
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
Period month
</label>
<input
type="month"
value={periodMonth}
onChange={(e) => setPeriodMonth(e.target.value)}
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{resourceMonthBlueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => applyBlueprint(blueprint)}
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
>
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
{blueprint.label}
</div>
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
{blueprint.description}
</p>
</button>
))}
</div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns
</div>
<div className="mt-2 flex flex-wrap gap-2">
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
<button
key={column}
type="button"
onClick={() => toggleColumn(column)}
className={clsx(
"rounded-full border px-3 py-1 text-xs font-medium transition",
selectedColumns.has(column)
? "border-emerald-500 bg-emerald-500 text-white"
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
)}
>
{columnLabelMap.get(column) ?? column}
</button>
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
</p>
</div>
</div>
)}
</div>
{/* Column Picker */}
@@ -276,6 +645,11 @@ export function ReportBuilder() {
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
{recommendedColumnSet.has(col.key) && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
Rec
</span>
)}
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
@@ -428,13 +802,18 @@ export function ReportBuilder() {
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
</p>
</div>
<button
type="button"