242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
DASHBOARD_GRID_COLUMNS,
|
|
DASHBOARD_LAYOUT_VERSION,
|
|
DASHBOARD_WIDGET_CATALOG,
|
|
DASHBOARD_WIDGET_TYPES,
|
|
type DashboardLayoutConfig,
|
|
type DashboardWidgetCatalogEntry,
|
|
type DashboardWidgetConfigMap,
|
|
type DashboardWidgetInstance,
|
|
type DashboardWidgetType,
|
|
} from "../types/dashboard.js";
|
|
import { ProjectStatus } from "../types/enums.js";
|
|
|
|
const DASHBOARD_WIDGET_BY_TYPE = Object.fromEntries(
|
|
DASHBOARD_WIDGET_CATALOG.map((widget) => [widget.type, widget]),
|
|
) as Record<DashboardWidgetType, DashboardWidgetCatalogEntry>;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function toInt(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
const dashboardWidgetTypeSchema = z.enum(DASHBOARD_WIDGET_TYPES);
|
|
|
|
const widgetChromeConfigSchema = z.object({
|
|
showDetails: z.boolean().optional(),
|
|
});
|
|
|
|
const resourceTableWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
chapter: z.preprocess(toNonEmptyString, z.string().optional()),
|
|
});
|
|
|
|
const projectTableWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
search: z.preprocess(toNonEmptyString, z.string().optional()),
|
|
status: z.nativeEnum(ProjectStatus).optional(),
|
|
});
|
|
|
|
const peakTimesWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
granularity: z.enum(["week", "month"]).optional(),
|
|
groupBy: z.enum(["project", "chapter", "resource"]).optional(),
|
|
});
|
|
|
|
const demandWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
groupBy: z.enum(["project", "person", "chapter"]).optional(),
|
|
});
|
|
|
|
const topValueWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
limit: z.number().int().min(1).max(100).optional(),
|
|
});
|
|
|
|
const chargeabilityWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
topN: z.number().int().min(1).max(100).optional(),
|
|
watchlistThreshold: z.number().int().min(0).max(100).optional(),
|
|
});
|
|
|
|
const myProjectsWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
|
showFavorites: z.boolean().optional(),
|
|
showResponsible: z.boolean().optional(),
|
|
});
|
|
|
|
export const dashboardWidgetConfigSchemas = {
|
|
"stat-cards": widgetChromeConfigSchema,
|
|
"resource-table": resourceTableWidgetConfigSchema,
|
|
"project-table": projectTableWidgetConfigSchema,
|
|
"peak-times-chart": peakTimesWidgetConfigSchema,
|
|
"demand-view": demandWidgetConfigSchema,
|
|
"top-value-resources": topValueWidgetConfigSchema,
|
|
"chargeability-overview": chargeabilityWidgetConfigSchema,
|
|
"my-projects": myProjectsWidgetConfigSchema,
|
|
"budget-forecast": widgetChromeConfigSchema,
|
|
"skill-gap": widgetChromeConfigSchema,
|
|
"project-health": widgetChromeConfigSchema,
|
|
} as const;
|
|
|
|
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
|
|
|
|
export function getDashboardWidgetCatalogEntry(type: DashboardWidgetType): DashboardWidgetCatalogEntry {
|
|
return DASHBOARD_WIDGET_BY_TYPE[type];
|
|
}
|
|
|
|
export function getNextDashboardWidgetY(widgets: DashboardWidgetInstance[]): number {
|
|
return widgets.reduce((max, widget) => Math.max(max, widget.y + widget.h), 0);
|
|
}
|
|
|
|
export function normalizeDashboardWidgetConfig<T extends DashboardWidgetType>(
|
|
type: T,
|
|
config: unknown,
|
|
): DashboardWidgetConfigMap[T] {
|
|
const schema = dashboardWidgetConfigSchemas[type];
|
|
const parsed = schema.safeParse(isRecord(config) ? config : {});
|
|
return {
|
|
...DASHBOARD_WIDGET_BY_TYPE[type].defaultConfig,
|
|
...(parsed.success ? parsed.data : {}),
|
|
} as DashboardWidgetConfigMap[T];
|
|
}
|
|
|
|
export function createDashboardWidget<T extends DashboardWidgetType>(
|
|
type: T,
|
|
options: {
|
|
id: string;
|
|
title?: string;
|
|
x?: number;
|
|
y?: number;
|
|
w?: number;
|
|
h?: number;
|
|
minW?: number;
|
|
minH?: number;
|
|
config?: unknown;
|
|
},
|
|
): DashboardWidgetInstance<T> {
|
|
const widgetDef = DASHBOARD_WIDGET_BY_TYPE[type];
|
|
const minW = Math.max(1, toInt(options.minW) ?? widgetDef.minSize.w);
|
|
const minH = Math.max(1, toInt(options.minH) ?? widgetDef.minSize.h);
|
|
const w = clamp(Math.max(minW, toInt(options.w) ?? widgetDef.defaultSize.w), minW, DASHBOARD_GRID_COLUMNS);
|
|
const h = Math.max(minH, toInt(options.h) ?? widgetDef.defaultSize.h);
|
|
const title = toNonEmptyString(options.title);
|
|
|
|
return {
|
|
id: options.id,
|
|
type,
|
|
x: clamp(Math.max(0, toInt(options.x) ?? 0), 0, Math.max(0, DASHBOARD_GRID_COLUMNS - w)),
|
|
y: Math.max(0, toInt(options.y) ?? 0),
|
|
w,
|
|
h,
|
|
minW,
|
|
minH,
|
|
config: normalizeDashboardWidgetConfig(type, options.config),
|
|
...(title ? { title } : {}),
|
|
};
|
|
}
|
|
|
|
export function createDefaultDashboardLayout(): DashboardLayoutConfig {
|
|
return {
|
|
version: DASHBOARD_LAYOUT_VERSION,
|
|
gridCols: DASHBOARD_GRID_COLUMNS,
|
|
widgets: [
|
|
createDashboardWidget("stat-cards", {
|
|
id: "default-stat-cards",
|
|
x: 0,
|
|
y: 0,
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
export function normalizeDashboardLayout(input: unknown): DashboardLayoutConfig {
|
|
if (!isRecord(input)) {
|
|
return createDefaultDashboardLayout();
|
|
}
|
|
|
|
const gridCols = clamp(Math.max(1, toInt(input.gridCols) ?? DASHBOARD_GRID_COLUMNS), 1, 24);
|
|
const rawWidgets = Array.isArray(input.widgets) ? input.widgets : [];
|
|
const widgets: DashboardWidgetInstance[] = [];
|
|
const seenIds = new Set<string>();
|
|
let nextY = 0;
|
|
|
|
rawWidgets.forEach((rawWidget, index) => {
|
|
if (!isRecord(rawWidget)) return;
|
|
|
|
const typeResult = dashboardWidgetTypeSchema.safeParse(rawWidget.type);
|
|
if (!typeResult.success) return;
|
|
|
|
const type = typeResult.data;
|
|
const baseId = toNonEmptyString(rawWidget.id) ?? `${type}-${index + 1}`;
|
|
let id = baseId;
|
|
let suffix = 1;
|
|
while (seenIds.has(id)) {
|
|
suffix += 1;
|
|
id = `${baseId}-${suffix}`;
|
|
}
|
|
seenIds.add(id);
|
|
|
|
const widgetOptions: Parameters<typeof createDashboardWidget<typeof type>>[1] = {
|
|
id,
|
|
...(typeof rawWidget.title === "string" ? { title: rawWidget.title } : {}),
|
|
...(typeof rawWidget.x === "number" ? { x: rawWidget.x } : {}),
|
|
...(typeof rawWidget.y === "number" ? { y: rawWidget.y } : {}),
|
|
...(typeof rawWidget.w === "number" ? { w: rawWidget.w } : {}),
|
|
...(typeof rawWidget.h === "number" ? { h: rawWidget.h } : {}),
|
|
...(typeof rawWidget.minW === "number" ? { minW: rawWidget.minW } : {}),
|
|
...(typeof rawWidget.minH === "number" ? { minH: rawWidget.minH } : {}),
|
|
...(rawWidget.config !== undefined ? { config: rawWidget.config } : {}),
|
|
};
|
|
|
|
const widget = createDashboardWidget(type, widgetOptions);
|
|
|
|
const placedWidget = {
|
|
...widget,
|
|
x: clamp(widget.x, 0, Math.max(0, gridCols - widget.w)),
|
|
y: toInt(rawWidget.y) !== undefined && (toInt(rawWidget.y) ?? 0) >= 0 ? widget.y : nextY,
|
|
w: clamp(widget.w, widget.minW, gridCols),
|
|
};
|
|
|
|
widgets.push(placedWidget);
|
|
nextY = Math.max(nextY, placedWidget.y + placedWidget.h);
|
|
});
|
|
|
|
return {
|
|
version: DASHBOARD_LAYOUT_VERSION,
|
|
gridCols,
|
|
widgets,
|
|
};
|
|
}
|
|
|
|
export const dashboardWidgetInstanceSchema = z.object({
|
|
id: z.string().min(1),
|
|
type: dashboardWidgetTypeSchema,
|
|
title: z.string().min(1).optional(),
|
|
x: z.number().int().min(0),
|
|
y: z.number().int().min(0),
|
|
w: z.number().int().min(1),
|
|
h: z.number().int().min(1),
|
|
minW: z.number().int().min(1),
|
|
minH: z.number().int().min(1),
|
|
config: z.record(z.unknown()),
|
|
});
|
|
|
|
export const dashboardLayoutSchema = z
|
|
.unknown()
|
|
.transform((value) => normalizeDashboardLayout(value))
|
|
.pipe(
|
|
z.object({
|
|
version: z.literal(DASHBOARD_LAYOUT_VERSION),
|
|
widgets: z.array(dashboardWidgetInstanceSchema),
|
|
gridCols: z.number().int().min(1).max(24),
|
|
}),
|
|
);
|