bfcadd2c52
Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines). Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1019 lines
39 KiB
TypeScript
1019 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useCallback } from "react";
|
|
import { keepPreviousData } from "@tanstack/react-query";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { clsx } from "clsx";
|
|
import { downloadWorkbookSheets } from "~/lib/workbook-export.js";
|
|
import {
|
|
buildReportWorkbookSheets,
|
|
type ReportExplainability,
|
|
} from "./reportBuilderExplainability.js";
|
|
import { ReportResultsPanel } from "./ReportResultsPanel.js";
|
|
import { ResourceMonthConfigSection } from "./ResourceMonthConfigSection.js";
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
type EntityType = "resource" | "project" | "assignment" | "resource_month";
|
|
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
|
|
|
interface FilterRow {
|
|
id: string;
|
|
field: string;
|
|
op: FilterOp;
|
|
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;
|
|
completeness: ResourceMonthTemplateCompleteness | null;
|
|
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 {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
entity: EntityType;
|
|
templateName: string;
|
|
config: TemplateConfig;
|
|
}
|
|
|
|
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 }[] = [
|
|
{ value: "eq", label: "equals" },
|
|
{ value: "neq", label: "not equals" },
|
|
{ value: "gt", label: "greater than" },
|
|
{ value: "lt", label: "less than" },
|
|
{ value: "gte", label: ">= (gte)" },
|
|
{ value: "lte", label: "<= (lte)" },
|
|
{ value: "contains", label: "contains" },
|
|
{ value: "in", label: "in (comma-sep)" },
|
|
];
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
|
"monthKey",
|
|
"displayName",
|
|
"eid",
|
|
"chapter",
|
|
"countryCode",
|
|
"countryName",
|
|
"federalState",
|
|
"metroCityName",
|
|
"orgUnitName",
|
|
"managementLevelGroupName",
|
|
"monthlyBaseWorkingDays",
|
|
"monthlyEffectiveWorkingDays",
|
|
"monthlyBaseAvailableHours",
|
|
"monthlyPublicHolidayCount",
|
|
"monthlyPublicHolidayWorkdayCount",
|
|
"monthlyPublicHolidayHoursDeduction",
|
|
"monthlyAbsenceDayEquivalent",
|
|
"monthlyAbsenceHoursDeduction",
|
|
"monthlySahHours",
|
|
"monthlyChargeabilityTargetPct",
|
|
"monthlyTargetHours",
|
|
"monthlyActualBookedHours",
|
|
"monthlyExpectedBookedHours",
|
|
"monthlyActualChargeabilityPct",
|
|
"monthlyExpectedChargeabilityPct",
|
|
"monthlyUnassignedHours",
|
|
] as const;
|
|
|
|
const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [
|
|
"monthKey",
|
|
"displayName",
|
|
"countryName",
|
|
"federalState",
|
|
"metroCityName",
|
|
"monthlyPublicHolidayCount",
|
|
"monthlyPublicHolidayHoursDeduction",
|
|
"monthlyAbsenceDayEquivalent",
|
|
"monthlyAbsenceHoursDeduction",
|
|
"monthlySahHours",
|
|
"monthlyTargetHours",
|
|
"monthlyActualBookedHours",
|
|
"monthlyUnassignedHours",
|
|
] as const;
|
|
|
|
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")}`;
|
|
}
|
|
|
|
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 ──────────────────────────────────────────────────────────────
|
|
|
|
export function ReportBuilder() {
|
|
// Config state
|
|
const [entity, setEntity] = useState<EntityType>("resource");
|
|
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set());
|
|
const [filters, setFilters] = useState<FilterRow[]>([]);
|
|
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 [templateDescription, setTemplateDescription] = useState<string>("");
|
|
const [templateIsShared, setTemplateIsShared] = useState(false);
|
|
const [page, setPage] = useState(0);
|
|
const [runQuery, setRunQuery] = useState(false);
|
|
|
|
// Fetch available columns when entity changes
|
|
const columnsQuery = trpc.report.getAvailableColumns.useQuery(
|
|
{ entity },
|
|
{ placeholderData: keepPreviousData },
|
|
);
|
|
|
|
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
|
const templatesQuery = trpc.report.listTemplates.useQuery();
|
|
const blueprintsQuery = trpc.report.listBlueprints.useQuery({ entity });
|
|
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("");
|
|
setTemplateDescription("");
|
|
setTemplateIsShared(false);
|
|
await templatesQuery.refetch();
|
|
},
|
|
});
|
|
|
|
// Scalar columns (for filter/sort/group — only non-relation columns)
|
|
const scalarColumns = useMemo(
|
|
() => availableColumns.filter((c) => !c.key.includes(".")),
|
|
[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
|
|
const queryInput = useMemo(() => {
|
|
if (!runQuery || selectedColumns.size === 0) return null;
|
|
return {
|
|
entity,
|
|
columns: Array.from(selectedColumns),
|
|
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, periodMonth]);
|
|
|
|
// Fetch report data
|
|
const reportQuery = trpc.report.getReportData.useQuery(queryInput!, {
|
|
enabled: queryInput !== null,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
|
|
const exportMutation = trpc.report.exportReport.useMutation();
|
|
|
|
// ─── Handlers ───────────────────────────────────────────────────────────
|
|
|
|
const handleEntityChange = useCallback((newEntity: EntityType) => {
|
|
setEntity(newEntity);
|
|
setSelectedTemplateId("");
|
|
setTemplateName("");
|
|
setTemplateDescription("");
|
|
setTemplateIsShared(false);
|
|
setSelectedColumns(new Set());
|
|
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);
|
|
setTemplateDescription(template.description ?? "");
|
|
setTemplateIsShared(template.isShared);
|
|
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) => {
|
|
const config = blueprint.config;
|
|
setSelectedTemplateId("");
|
|
setTemplateName(blueprint.templateName);
|
|
setTemplateDescription(blueprint.description);
|
|
setTemplateIsShared(false);
|
|
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");
|
|
if (config.entity === "resource_month") {
|
|
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
|
|
}
|
|
setRunQuery(false);
|
|
setPage(0);
|
|
}, []);
|
|
|
|
const toggleColumn = useCallback((key: string) => {
|
|
setSelectedColumns((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const selectAllColumns = useCallback(() => {
|
|
setSelectedColumns(new Set(availableColumns.map((c) => c.key)));
|
|
}, [availableColumns]);
|
|
|
|
const clearAllColumns = useCallback(() => {
|
|
setSelectedColumns(new Set());
|
|
}, []);
|
|
|
|
const addFilter = useCallback(() => {
|
|
const firstField = scalarColumns[0]?.key ?? "";
|
|
setFilters((prev) => [...prev, { id: generateId(), field: firstField, op: "eq", value: "" }]);
|
|
}, [scalarColumns]);
|
|
|
|
const updateFilter = useCallback((id: string, patch: Partial<FilterRow>) => {
|
|
setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
|
}, []);
|
|
|
|
const removeFilter = useCallback((id: string) => {
|
|
setFilters((prev) => prev.filter((f) => f.id !== id));
|
|
}, []);
|
|
|
|
const handleRun = useCallback(() => {
|
|
setPage(0);
|
|
setRunQuery(true);
|
|
}, []);
|
|
|
|
const handleSort = useCallback(
|
|
(column: string) => {
|
|
if (!column.includes(".")) {
|
|
if (sortBy === column) {
|
|
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
|
|
} else {
|
|
setSortBy(column);
|
|
setSortDir("asc");
|
|
}
|
|
// Re-run with new sort
|
|
setRunQuery(true);
|
|
}
|
|
},
|
|
[sortBy],
|
|
);
|
|
|
|
const handleExport = useCallback(async () => {
|
|
if (selectedColumns.size === 0) return;
|
|
try {
|
|
const result = (await exportMutation.mutateAsync({
|
|
entity,
|
|
columns: Array.from(selectedColumns),
|
|
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,
|
|
})) 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
|
|
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `report-${entity}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch {
|
|
// Error handled by tRPC
|
|
}
|
|
}, [
|
|
columnLabelMap,
|
|
entity,
|
|
selectedColumns,
|
|
filters,
|
|
groupBy,
|
|
sortBy,
|
|
sortDir,
|
|
exportMutation,
|
|
periodMonth,
|
|
]);
|
|
|
|
const handleSaveTemplate = useCallback(async () => {
|
|
if (!templateName.trim() || selectedColumns.size === 0) return;
|
|
const normalizedDescription = templateDescription.trim();
|
|
|
|
await saveTemplateMutation.mutateAsync({
|
|
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
|
name: templateName.trim(),
|
|
description: normalizedDescription,
|
|
isShared: templateIsShared,
|
|
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,
|
|
templateDescription,
|
|
templateIsShared,
|
|
templateName,
|
|
]);
|
|
|
|
const handleDeleteTemplate = useCallback(async () => {
|
|
if (!selectedTemplateId) return;
|
|
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
|
|
}, [deleteTemplateMutation, selectedTemplateId]);
|
|
|
|
// ─── Derived ──────────────────────────────────────────────────────────
|
|
|
|
const rows = reportQuery.data?.rows ?? [];
|
|
const totalCount = reportQuery.data?.totalCount ?? 0;
|
|
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 isLoading = reportQuery.isFetching;
|
|
const templates = templatesQuery.data ?? [];
|
|
const selectedTemplate =
|
|
templates.find((template: ReportTemplateSummary) => template.id === selectedTemplateId) ?? null;
|
|
const resourceMonthBlueprints = useMemo(
|
|
() =>
|
|
((blueprintsQuery.data ?? []) as ReportBlueprint[]).filter(
|
|
(blueprint) => blueprint.entity === entity,
|
|
),
|
|
[blueprintsQuery.data, entity],
|
|
);
|
|
const recommendedColumnSet = useMemo(
|
|
() =>
|
|
entity === "resource_month"
|
|
? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS)
|
|
: new Set<string>(),
|
|
[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],
|
|
);
|
|
|
|
const displayedResourceMonthCompleteness = useMemo(() => {
|
|
if (entity !== "resource_month") {
|
|
return null;
|
|
}
|
|
if (
|
|
selectedTemplate &&
|
|
!hasTemplateDraftChanges &&
|
|
selectedTemplate.completeness?.scope === "resource_month"
|
|
) {
|
|
return selectedTemplate.completeness;
|
|
}
|
|
return currentResourceMonthCompleteness;
|
|
}, [currentResourceMonthCompleteness, entity, hasTemplateDraftChanges, selectedTemplate]);
|
|
// ─── Render ───────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="mx-auto max-w-[1600px] space-y-6 p-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Report Builder</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Build custom reports by selecting an entity, columns, and filters.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 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>
|
|
{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 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>
|
|
<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
|
|
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">
|
|
Entity
|
|
</label>
|
|
<div className="flex gap-3">
|
|
{ENTITY_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
onClick={() => handleEntityChange(opt.value)}
|
|
className={clsx(
|
|
"rounded-xl px-4 py-2 text-sm font-medium transition-colors",
|
|
entity === opt.value
|
|
? "bg-brand-600 text-white shadow-sm"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-slate-800 dark:text-gray-300 dark:hover:bg-slate-700",
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{entity === "resource_month" && (
|
|
<ResourceMonthConfigSection
|
|
periodMonth={periodMonth}
|
|
onPeriodMonthChange={setPeriodMonth}
|
|
blueprints={resourceMonthBlueprints}
|
|
onApplyBlueprint={applyBlueprint}
|
|
completeness={displayedResourceMonthCompleteness}
|
|
selectedTemplate={selectedTemplate}
|
|
hasTemplateDraftChanges={hasTemplateDraftChanges}
|
|
selectedColumns={selectedColumns}
|
|
onToggleColumn={toggleColumn}
|
|
columnLabelMap={columnLabelMap}
|
|
recommendedColumns={RESOURCE_MONTH_RECOMMENDED_COLUMNS}
|
|
summarizeMissing={summarizeMissingColumns}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Column Picker */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Columns ({selectedColumns.size} selected)
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={selectAllColumns}
|
|
className="text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
|
>
|
|
Select all
|
|
</button>
|
|
<span className="text-xs text-gray-300 dark:text-gray-600">|</span>
|
|
<button
|
|
type="button"
|
|
onClick={clearAllColumns}
|
|
className="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{columnsQuery.isLoading ? (
|
|
<div className="py-3 text-sm text-gray-400">Loading columns...</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
{availableColumns.map((col) => (
|
|
<label
|
|
key={col.key}
|
|
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-gray-50 dark:hover:bg-slate-900"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedColumns.has(col.key)}
|
|
onChange={() => toggleColumn(col.key)}
|
|
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>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filter Builder */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Filters</label>
|
|
<button
|
|
type="button"
|
|
onClick={addFilter}
|
|
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
|
>
|
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
Add filter
|
|
</button>
|
|
</div>
|
|
{filters.length === 0 ? (
|
|
<p className="text-sm text-gray-400 dark:text-gray-500">No filters applied.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filters.map((filter) => (
|
|
<div key={filter.id} className="flex items-center gap-2">
|
|
{/* Field */}
|
|
<select
|
|
value={filter.field}
|
|
onChange={(e) => updateFilter(filter.id, { field: e.target.value })}
|
|
className="w-44 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
|
|
>
|
|
{scalarColumns.map((col) => (
|
|
<option key={col.key} value={col.key}>
|
|
{col.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Operator */}
|
|
<select
|
|
value={filter.op}
|
|
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
|
|
className="w-36 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
|
|
>
|
|
{OPERATOR_OPTIONS.map((op) => (
|
|
<option key={op.value} value={op.value}>
|
|
{op.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Value */}
|
|
<input
|
|
type="text"
|
|
value={filter.value}
|
|
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
|
|
placeholder="Value..."
|
|
className="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300 dark:placeholder:text-gray-600"
|
|
/>
|
|
|
|
{/* Remove */}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeFilter(filter.id)}
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950 dark:hover:text-red-400"
|
|
title="Remove filter"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sort & Group */}
|
|
<div className="flex flex-wrap gap-4">
|
|
<div className="min-w-[160px]">
|
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Group by
|
|
</label>
|
|
<select
|
|
value={groupBy}
|
|
onChange={(e) => setGroupBy(e.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
|
|
>
|
|
<option value="">None</option>
|
|
{scalarColumns.map((col) => (
|
|
<option key={col.key} value={col.key}>
|
|
{col.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="min-w-[160px]">
|
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Sort by
|
|
</label>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
|
|
>
|
|
<option value="">Default</option>
|
|
{scalarColumns.map((col) => (
|
|
<option key={col.key} value={col.key}>
|
|
{col.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="min-w-[120px]">
|
|
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Direction
|
|
</label>
|
|
<select
|
|
value={sortDir}
|
|
onChange={(e) => setSortDir(e.target.value as "asc" | "desc")}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-900 dark:text-gray-300"
|
|
>
|
|
<option value="asc">Ascending</option>
|
|
<option value="desc">Descending</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Run button */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleRun}
|
|
disabled={selectedColumns.size === 0}
|
|
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
Run Report
|
|
</button>
|
|
{selectedColumns.size === 0 && (
|
|
<span className="text-sm text-gray-400 dark:text-gray-500">
|
|
Select at least one column
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
{runQuery && (
|
|
<ReportResultsPanel
|
|
rows={rows}
|
|
totalCount={totalCount}
|
|
outputColumns={outputColumns}
|
|
groups={reportGroups}
|
|
explainability={reportExplainability}
|
|
groupBy={groupBy}
|
|
sortBy={sortBy}
|
|
sortDir={sortDir}
|
|
isLoading={isLoading}
|
|
page={page}
|
|
pageSize={PAGE_SIZE}
|
|
columnLabelMap={columnLabelMap}
|
|
exportPending={exportMutation.isPending}
|
|
onSort={handleSort}
|
|
onExport={() => void handleExport()}
|
|
onPageChange={setPage}
|
|
summarizeMissing={summarizeMissingColumns}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|