Org assignment history, mobile support, and a correctness pass
Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { todayIso } from "./format";
|
||||
|
||||
// Shared by every /api/export/* route: define columns once as { header, get },
|
||||
// get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from
|
||||
@@ -38,8 +39,12 @@ export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
||||
return "" + lines.join("\r\n");
|
||||
}
|
||||
|
||||
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
|
||||
// serial straight off getTime() with no timezone adjustment, so a Date built
|
||||
// at *local* midnight in a positive-offset zone (Vienna) lands on the previous
|
||||
// day's serial and every date cell in the workbook renders one day early.
|
||||
function parseIsoDate(value: string): Date | null {
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
@@ -47,9 +52,12 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName.slice(0, 31));
|
||||
|
||||
sheet.columns = columns.map((c) => ({
|
||||
// Keyed by position, not by header text: split columns take their header
|
||||
// from the data (a team name, a weekday), so two columns can legitimately
|
||||
// collide — and ExcelJS silently drops the second one when two share a key.
|
||||
sheet.columns = columns.map((c, i) => ({
|
||||
header: c.header,
|
||||
key: c.header,
|
||||
key: String(i),
|
||||
width: Math.min(40, Math.max(12, c.header.length + 4)),
|
||||
style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined,
|
||||
}));
|
||||
@@ -59,9 +67,9 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
|
||||
|
||||
for (const row of rows) {
|
||||
const record: Record<string, string | number | boolean | Date | null> = {};
|
||||
for (const c of columns) {
|
||||
for (const [i, c] of columns.entries()) {
|
||||
const value = c.get(row);
|
||||
record[c.header] =
|
||||
record[String(i)] =
|
||||
c.kind === "date" && typeof value === "string" && value
|
||||
? (parseIsoDate(value) ?? value)
|
||||
: typeof value === "string"
|
||||
@@ -75,9 +83,18 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
|
||||
return new Uint8Array(written);
|
||||
}
|
||||
|
||||
// The base carries values that originate in the query string (event type,
|
||||
// measure, dimension) and ends up inside a Content-Disposition header, so it
|
||||
// is reduced to a filename-safe slug here rather than trusted. Callers also
|
||||
// validate those params; this is the backstop that makes header injection
|
||||
// impossible regardless.
|
||||
export function exportFilename(base: string, format: "csv" | "xlsx"): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `${base}-${today}.${format}`;
|
||||
const slug = base
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return `${slug || "export"}-${todayIso()}.${format}`;
|
||||
}
|
||||
|
||||
export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit {
|
||||
|
||||
Reference in New Issue
Block a user