feat(web): strengthen report builder explainability
This commit is contained in:
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { Fragment, useState, useMemo, useCallback } from "react";
|
||||||
import { keepPreviousData } from "@tanstack/react-query";
|
import { keepPreviousData } from "@tanstack/react-query";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
|
||||||
|
import {
|
||||||
|
buildReportWorkbookSheets,
|
||||||
|
type ReportExplainability,
|
||||||
|
} from "./reportBuilderExplainability.js";
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -41,19 +46,45 @@ interface ReportTemplateSummary {
|
|||||||
config: TemplateConfig;
|
config: TemplateConfig;
|
||||||
isShared: boolean;
|
isShared: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
completeness: ResourceMonthTemplateCompleteness | null;
|
||||||
updatedAt: string | Date;
|
updatedAt: string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResourceMonthTemplateCompleteness {
|
||||||
|
scope: "resource_month";
|
||||||
|
isAuditReady: boolean;
|
||||||
|
isRecommendedComplete: boolean;
|
||||||
|
recommendedColumnCount: number;
|
||||||
|
selectedRecommendedColumnCount: number;
|
||||||
|
minimumAuditColumnCount: number;
|
||||||
|
selectedMinimumAuditColumnCount: number;
|
||||||
|
missingRecommendedColumns: string[];
|
||||||
|
missingMinimumAuditColumns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportGroupSummary {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
rowCount: number;
|
||||||
|
startIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportReportResult {
|
||||||
|
csv: string;
|
||||||
|
rowCount: number;
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
columns: string[];
|
||||||
|
groups: ReportGroupSummary[];
|
||||||
|
explainability?: ReportExplainability;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReportBlueprint {
|
interface ReportBlueprint {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
entity: EntityType;
|
entity: EntityType;
|
||||||
columns: string[];
|
|
||||||
groupBy?: string;
|
|
||||||
sortBy?: string;
|
|
||||||
sortDir?: "asc" | "desc";
|
|
||||||
templateName: string;
|
templateName: string;
|
||||||
|
config: TemplateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
||||||
@@ -77,6 +108,7 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
|||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
||||||
|
"monthKey",
|
||||||
"displayName",
|
"displayName",
|
||||||
"eid",
|
"eid",
|
||||||
"chapter",
|
"chapter",
|
||||||
@@ -84,9 +116,12 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
|||||||
"countryName",
|
"countryName",
|
||||||
"federalState",
|
"federalState",
|
||||||
"metroCityName",
|
"metroCityName",
|
||||||
|
"orgUnitName",
|
||||||
|
"managementLevelGroupName",
|
||||||
"monthlyBaseWorkingDays",
|
"monthlyBaseWorkingDays",
|
||||||
"monthlyEffectiveWorkingDays",
|
"monthlyEffectiveWorkingDays",
|
||||||
"monthlyBaseAvailableHours",
|
"monthlyBaseAvailableHours",
|
||||||
|
"monthlyPublicHolidayCount",
|
||||||
"monthlyPublicHolidayWorkdayCount",
|
"monthlyPublicHolidayWorkdayCount",
|
||||||
"monthlyPublicHolidayHoursDeduction",
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
"monthlyAbsenceDayEquivalent",
|
"monthlyAbsenceDayEquivalent",
|
||||||
@@ -101,85 +136,21 @@ const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
|||||||
"monthlyUnassignedHours",
|
"monthlyUnassignedHours",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
|
const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
|
||||||
{
|
"monthKey",
|
||||||
id: "resource-month-sah-transparency",
|
"displayName",
|
||||||
label: "SAH transparency",
|
"countryName",
|
||||||
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
|
"federalState",
|
||||||
entity: "resource_month",
|
"metroCityName",
|
||||||
templateName: "Monthly SAH transparency",
|
"monthlyPublicHolidayCount",
|
||||||
columns: [
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
"displayName",
|
"monthlyAbsenceDayEquivalent",
|
||||||
"eid",
|
"monthlyAbsenceHoursDeduction",
|
||||||
"chapter",
|
"monthlySahHours",
|
||||||
"countryName",
|
"monthlyTargetHours",
|
||||||
"federalState",
|
"monthlyActualBookedHours",
|
||||||
"metroCityName",
|
"monthlyUnassignedHours",
|
||||||
"monthlyBaseWorkingDays",
|
] as const;
|
||||||
"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 {
|
function generateId(): string {
|
||||||
return Math.random().toString(36).slice(2, 10);
|
return Math.random().toString(36).slice(2, 10);
|
||||||
@@ -190,6 +161,64 @@ function getCurrentPeriodMonth(): string {
|
|||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTemplateConfig(config: TemplateConfig): TemplateConfig {
|
||||||
|
return {
|
||||||
|
entity: config.entity,
|
||||||
|
columns: [...config.columns].sort(),
|
||||||
|
filters: [...config.filters]
|
||||||
|
.map((filter) => ({
|
||||||
|
field: filter.field,
|
||||||
|
op: filter.op,
|
||||||
|
value: filter.value,
|
||||||
|
}))
|
||||||
|
.sort((left, right) =>
|
||||||
|
`${left.field}:${left.op}:${left.value}`.localeCompare(
|
||||||
|
`${right.field}:${right.op}:${right.value}`,
|
||||||
|
)),
|
||||||
|
...(config.groupBy ? { groupBy: config.groupBy } : {}),
|
||||||
|
...(config.sortBy ? { sortBy: config.sortBy } : {}),
|
||||||
|
...(config.sortBy ? { sortDir: config.sortDir ?? "asc" } : {}),
|
||||||
|
...(config.entity === "resource_month" ? { periodMonth: config.periodMonth ?? "" } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTemplateConfig(config: TemplateConfig): string {
|
||||||
|
return JSON.stringify(normalizeTemplateConfig(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResourceMonthCompleteness(columns: Iterable<string>): ResourceMonthTemplateCompleteness {
|
||||||
|
const selectedColumns = new Set(columns);
|
||||||
|
const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS
|
||||||
|
.filter((column) => !selectedColumns.has(column));
|
||||||
|
const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS
|
||||||
|
.filter((column) => !selectedColumns.has(column));
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope: "resource_month",
|
||||||
|
isAuditReady: missingMinimumAuditColumns.length === 0,
|
||||||
|
isRecommendedComplete: missingRecommendedColumns.length === 0,
|
||||||
|
recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length,
|
||||||
|
selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length,
|
||||||
|
minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length,
|
||||||
|
selectedMinimumAuditColumnCount:
|
||||||
|
RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length,
|
||||||
|
missingRecommendedColumns,
|
||||||
|
missingMinimumAuditColumns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMissingColumns(
|
||||||
|
columns: string[],
|
||||||
|
columnLabelMap: Map<string, string>,
|
||||||
|
limit = 6,
|
||||||
|
): string {
|
||||||
|
const labels = columns.map((column) => columnLabelMap.get(column) ?? column);
|
||||||
|
if (labels.length <= limit) {
|
||||||
|
return labels.join(", ");
|
||||||
|
}
|
||||||
|
return `${labels.slice(0, limit).join(", ")} +${labels.length - limit} more`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ReportBuilder() {
|
export function ReportBuilder() {
|
||||||
@@ -203,6 +232,8 @@ export function ReportBuilder() {
|
|||||||
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
|
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
const [templateName, setTemplateName] = useState<string>("");
|
const [templateName, setTemplateName] = useState<string>("");
|
||||||
|
const [templateDescription, setTemplateDescription] = useState<string>("");
|
||||||
|
const [templateIsShared, setTemplateIsShared] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [runQuery, setRunQuery] = useState(false);
|
const [runQuery, setRunQuery] = useState(false);
|
||||||
|
|
||||||
@@ -214,6 +245,7 @@ export function ReportBuilder() {
|
|||||||
|
|
||||||
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
||||||
const templatesQuery = trpc.report.listTemplates.useQuery();
|
const templatesQuery = trpc.report.listTemplates.useQuery();
|
||||||
|
const blueprintsQuery = trpc.report.listBlueprints.useQuery({ entity });
|
||||||
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
|
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
setSelectedTemplateId(result.id);
|
setSelectedTemplateId(result.id);
|
||||||
@@ -224,6 +256,8 @@ export function ReportBuilder() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateName("");
|
setTemplateName("");
|
||||||
|
setTemplateDescription("");
|
||||||
|
setTemplateIsShared(false);
|
||||||
await templatesQuery.refetch();
|
await templatesQuery.refetch();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -233,6 +267,13 @@ export function ReportBuilder() {
|
|||||||
() => availableColumns.filter((c) => !c.key.includes(".")),
|
() => availableColumns.filter((c) => !c.key.includes(".")),
|
||||||
[availableColumns],
|
[availableColumns],
|
||||||
);
|
);
|
||||||
|
const columnLabelMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const col of availableColumns) {
|
||||||
|
map.set(col.key, col.label);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [availableColumns]);
|
||||||
|
|
||||||
// Build query input
|
// Build query input
|
||||||
const queryInput = useMemo(() => {
|
const queryInput = useMemo(() => {
|
||||||
@@ -263,6 +304,10 @@ export function ReportBuilder() {
|
|||||||
|
|
||||||
const handleEntityChange = useCallback((newEntity: EntityType) => {
|
const handleEntityChange = useCallback((newEntity: EntityType) => {
|
||||||
setEntity(newEntity);
|
setEntity(newEntity);
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setTemplateName("");
|
||||||
|
setTemplateDescription("");
|
||||||
|
setTemplateIsShared(false);
|
||||||
setSelectedColumns(new Set());
|
setSelectedColumns(new Set());
|
||||||
setFilters([]);
|
setFilters([]);
|
||||||
setGroupBy("");
|
setGroupBy("");
|
||||||
@@ -278,6 +323,8 @@ export function ReportBuilder() {
|
|||||||
const config = template.config;
|
const config = template.config;
|
||||||
setSelectedTemplateId(template.id);
|
setSelectedTemplateId(template.id);
|
||||||
setTemplateName(template.name);
|
setTemplateName(template.name);
|
||||||
|
setTemplateDescription(template.description ?? "");
|
||||||
|
setTemplateIsShared(template.isShared);
|
||||||
setEntity(config.entity);
|
setEntity(config.entity);
|
||||||
setSelectedColumns(new Set(config.columns));
|
setSelectedColumns(new Set(config.columns));
|
||||||
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
||||||
@@ -290,16 +337,19 @@ export function ReportBuilder() {
|
|||||||
}, [templatesQuery.data]);
|
}, [templatesQuery.data]);
|
||||||
|
|
||||||
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
|
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
|
||||||
|
const config = blueprint.config;
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateName(blueprint.templateName);
|
setTemplateName(blueprint.templateName);
|
||||||
setEntity(blueprint.entity);
|
setTemplateDescription(blueprint.description);
|
||||||
setSelectedColumns(new Set(blueprint.columns));
|
setTemplateIsShared(false);
|
||||||
setFilters([]);
|
setEntity(config.entity);
|
||||||
setGroupBy(blueprint.groupBy ?? "");
|
setSelectedColumns(new Set(config.columns));
|
||||||
setSortBy(blueprint.sortBy ?? "");
|
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
||||||
setSortDir(blueprint.sortDir ?? "asc");
|
setGroupBy(config.groupBy ?? "");
|
||||||
if (blueprint.entity === "resource_month") {
|
setSortBy(config.sortBy ?? "");
|
||||||
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
setSortDir(config.sortDir ?? "asc");
|
||||||
|
if (config.entity === "resource_month") {
|
||||||
|
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
|
||||||
}
|
}
|
||||||
setRunQuery(false);
|
setRunQuery(false);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
@@ -369,7 +419,22 @@ export function ReportBuilder() {
|
|||||||
...(groupBy ? { groupBy } : {}),
|
...(groupBy ? { groupBy } : {}),
|
||||||
...(sortBy ? { sortBy, sortDir } : {}),
|
...(sortBy ? { sortBy, sortDir } : {}),
|
||||||
limit: 5000,
|
limit: 5000,
|
||||||
});
|
}) as ExportReportResult;
|
||||||
|
|
||||||
|
if (result.explainability?.entity === "resource_month") {
|
||||||
|
await downloadWorkbookSheets(
|
||||||
|
`report-${entity}-${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||||
|
buildReportWorkbookSheets({
|
||||||
|
columns: result.columns,
|
||||||
|
rows: result.rows,
|
||||||
|
groups: result.groups,
|
||||||
|
...(groupBy ? { groupBy } : {}),
|
||||||
|
explainability: result.explainability,
|
||||||
|
resolveColumnLabel: (column) => columnLabelMap.get(column) ?? column,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Download CSV
|
// Download CSV
|
||||||
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
|
||||||
@@ -382,14 +447,17 @@ export function ReportBuilder() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Error handled by tRPC
|
// Error handled by tRPC
|
||||||
}
|
}
|
||||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
|
}, [columnLabelMap, entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
|
||||||
|
|
||||||
const handleSaveTemplate = useCallback(async () => {
|
const handleSaveTemplate = useCallback(async () => {
|
||||||
if (!templateName.trim() || selectedColumns.size === 0) return;
|
if (!templateName.trim() || selectedColumns.size === 0) return;
|
||||||
|
const normalizedDescription = templateDescription.trim();
|
||||||
|
|
||||||
await saveTemplateMutation.mutateAsync({
|
await saveTemplateMutation.mutateAsync({
|
||||||
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
||||||
name: templateName.trim(),
|
name: templateName.trim(),
|
||||||
|
description: normalizedDescription,
|
||||||
|
isShared: templateIsShared,
|
||||||
config: {
|
config: {
|
||||||
entity,
|
entity,
|
||||||
columns: Array.from(selectedColumns),
|
columns: Array.from(selectedColumns),
|
||||||
@@ -411,6 +479,8 @@ export function ReportBuilder() {
|
|||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortDir,
|
sortDir,
|
||||||
|
templateDescription,
|
||||||
|
templateIsShared,
|
||||||
templateName,
|
templateName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -424,26 +494,76 @@ export function ReportBuilder() {
|
|||||||
const rows = reportQuery.data?.rows ?? [];
|
const rows = reportQuery.data?.rows ?? [];
|
||||||
const totalCount = reportQuery.data?.totalCount ?? 0;
|
const totalCount = reportQuery.data?.totalCount ?? 0;
|
||||||
const outputColumns = reportQuery.data?.columns ?? [];
|
const outputColumns = reportQuery.data?.columns ?? [];
|
||||||
|
const reportGroups = (reportQuery.data?.groups ?? []) as ReportGroupSummary[];
|
||||||
|
const reportExplainability = reportQuery.data?.explainability as ReportExplainability | undefined;
|
||||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
const isLoading = reportQuery.isFetching;
|
const isLoading = reportQuery.isFetching;
|
||||||
const templates = templatesQuery.data ?? [];
|
const templates = templatesQuery.data ?? [];
|
||||||
|
const selectedTemplate =
|
||||||
|
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
|
||||||
const resourceMonthBlueprints = useMemo(
|
const resourceMonthBlueprints = useMemo(
|
||||||
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
|
() => ((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter((blueprint) => blueprint.entity === entity),
|
||||||
[entity],
|
[blueprintsQuery.data, entity],
|
||||||
);
|
);
|
||||||
const recommendedColumnSet = useMemo(
|
const recommendedColumnSet = useMemo(
|
||||||
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
|
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
|
||||||
[entity],
|
[entity],
|
||||||
);
|
);
|
||||||
|
const currentTemplateConfig = useMemo<TemplateConfig>(() => ({
|
||||||
|
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, selectedColumns, sortBy, sortDir]);
|
||||||
|
const selectedTemplateFingerprint = selectedTemplate
|
||||||
|
? serializeTemplateConfig(selectedTemplate.config)
|
||||||
|
: null;
|
||||||
|
const currentTemplateFingerprint = serializeTemplateConfig(currentTemplateConfig);
|
||||||
|
const selectedTemplateMetadataFingerprint = selectedTemplate
|
||||||
|
? JSON.stringify({
|
||||||
|
name: selectedTemplate.name,
|
||||||
|
description: selectedTemplate.description ?? "",
|
||||||
|
isShared: selectedTemplate.isShared,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const currentTemplateMetadataFingerprint = JSON.stringify({
|
||||||
|
name: templateName.trim(),
|
||||||
|
description: templateDescription.trim(),
|
||||||
|
isShared: templateIsShared,
|
||||||
|
});
|
||||||
|
const hasTemplateDraftChanges = selectedTemplateFingerprint !== null
|
||||||
|
&& (
|
||||||
|
selectedTemplateFingerprint !== currentTemplateFingerprint
|
||||||
|
|| selectedTemplateMetadataFingerprint !== currentTemplateMetadataFingerprint
|
||||||
|
);
|
||||||
|
const hasUnsavedLocalView = selectedTemplate === null
|
||||||
|
&& (selectedColumns.size > 0 || filters.some((filter) => filter.field && filter.value));
|
||||||
|
const currentResourceMonthCompleteness = useMemo(
|
||||||
|
() => entity === "resource_month" ? buildResourceMonthCompleteness(selectedColumns) : null,
|
||||||
|
[entity, selectedColumns],
|
||||||
|
);
|
||||||
|
|
||||||
// Column label lookup
|
const displayedResourceMonthCompleteness = useMemo(() => {
|
||||||
const columnLabelMap = useMemo(() => {
|
if (entity !== "resource_month") {
|
||||||
const map = new Map<string, string>();
|
return null;
|
||||||
for (const col of availableColumns) {
|
|
||||||
map.set(col.key, col.label);
|
|
||||||
}
|
}
|
||||||
return map;
|
if (
|
||||||
}, [availableColumns]);
|
selectedTemplate
|
||||||
|
&& !hasTemplateDraftChanges
|
||||||
|
&& selectedTemplate.completeness?.scope === "resource_month"
|
||||||
|
) {
|
||||||
|
return selectedTemplate.completeness;
|
||||||
|
}
|
||||||
|
return currentResourceMonthCompleteness;
|
||||||
|
}, [currentResourceMonthCompleteness, entity, hasTemplateDraftChanges, selectedTemplate]);
|
||||||
|
const groupStartByIndex = useMemo(
|
||||||
|
() => new Map(reportGroups.map((group) => [group.startIndex, group] as const)),
|
||||||
|
[reportGroups],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────────
|
// ─── Render ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -483,6 +603,40 @@ export function ReportBuilder() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedTemplate ? (
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px]">
|
||||||
|
<span className="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-700 dark:bg-slate-800 dark:text-gray-200">
|
||||||
|
{selectedTemplate.isShared ? "Shared template" : "Private template"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
|
||||||
|
{selectedTemplate.isOwner ? "Owned by you" : "Shared by another user"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-2 py-0.5 font-medium",
|
||||||
|
hasTemplateDraftChanges
|
||||||
|
? "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200"
|
||||||
|
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasTemplateDraftChanges ? "Modified locally" : "Saved state"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2 text-[11px]">
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
|
||||||
|
{hasUnsavedLocalView ? "Unsaved local view" : "No template selected"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-slate-900 dark:text-gray-300">
|
||||||
|
Save the current builder state as a reusable template or shared view
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedTemplate && hasTemplateDraftChanges ? (
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
The current builder state differs from the saved template. Use “Update template” to persist these local changes.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -496,6 +650,27 @@ export function ReportBuilder() {
|
|||||||
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"
|
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>
|
</div>
|
||||||
|
<div className="space-y-2 lg:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={templateDescription}
|
||||||
|
onChange={(e) => setTemplateDescription(e.target.value)}
|
||||||
|
placeholder="Explains which basis columns and metrics this template is meant to audit"
|
||||||
|
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>
|
||||||
|
<label className="flex items-center gap-2 self-end rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={templateIsShared}
|
||||||
|
onChange={(e) => setTemplateIsShared(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
|
||||||
|
/>
|
||||||
|
Shared with controllers
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleSaveTemplate()}
|
onClick={() => void handleSaveTemplate()}
|
||||||
@@ -574,6 +749,52 @@ export function ReportBuilder() {
|
|||||||
</div>
|
</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="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
|
||||||
|
{displayedResourceMonthCompleteness ? (
|
||||||
|
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
|
||||||
|
displayedResourceMonthCompleteness.isAuditReady
|
||||||
|
? "bg-emerald-500 text-white"
|
||||||
|
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayedResourceMonthCompleteness.isAuditReady ? "Audit ready" : "Audit gap"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
{displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/
|
||||||
|
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit columns
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
{displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/
|
||||||
|
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended columns
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
|
||||||
|
{selectedTemplate && !hasTemplateDraftChanges ? "Saved template status" : "Current builder status"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
Missing audit/export basis columns: {summarizeMissingColumns(
|
||||||
|
displayedResourceMonthCompleteness.missingMinimumAuditColumns,
|
||||||
|
columnLabelMap,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
|
||||||
|
Audit-ready, but still missing recommended basis columns: {summarizeMissingColumns(
|
||||||
|
displayedResourceMonthCompleteness.missingRecommendedColumns,
|
||||||
|
columnLabelMap,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
|
||||||
|
This view includes the full recommended audit/export basis set for monthly SAH and chargeability checks.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
|
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
|
||||||
Recommended transparency columns
|
Recommended transparency columns
|
||||||
</div>
|
</div>
|
||||||
@@ -600,6 +821,9 @@ export function ReportBuilder() {
|
|||||||
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
<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.
|
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>
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||||
|
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions, target hours, booked hours and unassigned hours.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -812,8 +1036,15 @@ export function ReportBuilder() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<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.
|
{reportExplainability?.entity === "resource_month"
|
||||||
|
? "Exports include the report sheet plus an Explainability sheet with location, holiday, absence and SAH basis."
|
||||||
|
: "CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here."}
|
||||||
</p>
|
</p>
|
||||||
|
{groupBy && rows.length > 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Grouped by {columnLabelMap.get(groupBy) ?? groupBy} with page-local section headers.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -824,10 +1055,51 @@ export function ReportBuilder() {
|
|||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
{exportMutation.isPending ? "Exporting..." : "Export CSV"}
|
{exportMutation.isPending ? "Exporting..." : reportExplainability?.entity === "resource_month" ? "Export XLSX" : "Export CSV"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{reportExplainability?.entity === "resource_month" ? (
|
||||||
|
<div className="border-b border-emerald-100 bg-emerald-50/70 px-6 py-4 text-sm dark:border-emerald-950/60 dark:bg-emerald-950/20">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
Month: {reportExplainability.periodMonth ?? "current"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
Location: {(reportExplainability.locationContextColumns.length > 0
|
||||||
|
? reportExplainability.locationContextColumns
|
||||||
|
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
Holidays: {(reportExplainability.holidayMetricColumns.length > 0
|
||||||
|
? reportExplainability.holidayMetricColumns
|
||||||
|
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
Absences: {(reportExplainability.absenceMetricColumns.length > 0
|
||||||
|
? reportExplainability.absenceMetricColumns
|
||||||
|
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
|
||||||
|
Capacity: {(reportExplainability.capacityMetricColumns.length > 0
|
||||||
|
? reportExplainability.capacityMetricColumns
|
||||||
|
: ["none"]).map((column) => columnLabelMap.get(column) ?? column).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
|
||||||
|
{reportExplainability.notes.join(" ")}
|
||||||
|
</p>
|
||||||
|
{reportExplainability.missingRecommendedColumns.length > 0 ? (
|
||||||
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Missing recommended audit columns: {summarizeMissingColumns(
|
||||||
|
reportExplainability.missingRecommendedColumns,
|
||||||
|
columnLabelMap,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -868,21 +1140,37 @@ export function ReportBuilder() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-800/60">
|
||||||
{rows.map((row, idx) => (
|
{rows.map((row, idx) => {
|
||||||
<tr
|
const group = groupStartByIndex.get(idx);
|
||||||
key={(row.id as string) ?? idx}
|
|
||||||
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
|
return (
|
||||||
>
|
<Fragment key={`grouped-row:${String(row.id ?? idx)}:${idx}`}>
|
||||||
{outputColumns.map((col) => (
|
{group ? (
|
||||||
<td
|
<tr key={`${group.key}:${idx}`} className="bg-brand-50/70 dark:bg-brand-950/20">
|
||||||
key={col}
|
<td
|
||||||
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
|
colSpan={outputColumns.length}
|
||||||
|
className="px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-brand-700 dark:text-brand-200"
|
||||||
|
>
|
||||||
|
{columnLabelMap.get(groupBy) ?? groupBy}: {group.label} · {group.rowCount} row{group.rowCount === 1 ? "" : "s"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
<tr
|
||||||
|
key={(row.id as string) ?? idx}
|
||||||
|
className="transition-colors hover:bg-gray-50/60 dark:hover:bg-slate-900/40"
|
||||||
>
|
>
|
||||||
{formatCellValue(row[col])}
|
{outputColumns.map((col) => (
|
||||||
</td>
|
<td
|
||||||
))}
|
key={col}
|
||||||
</tr>
|
className="whitespace-nowrap px-4 py-2.5 text-gray-700 dark:text-gray-300"
|
||||||
))}
|
>
|
||||||
|
{formatCellValue(row[col])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user