Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.
Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
This commit is contained in:
90
lib/export.ts
Normal file
90
lib/export.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
// 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
|
||||
// the same data + column definitions. kind: "date" tells the xlsx writer to
|
||||
// emit a real date cell (not a text string) for ISO ("YYYY-MM-DD") values.
|
||||
export type ExportColumn<T> = {
|
||||
header: string;
|
||||
get: (row: T) => string | number | boolean | null;
|
||||
kind?: "date";
|
||||
};
|
||||
|
||||
// CSV/Excel formula injection (CWE-1236): a cell whose text begins with
|
||||
// =, +, -, or @ is interpreted as a formula by Excel/Sheets/LibreOffice on
|
||||
// open, not as literal text — dangerous when the source data (employee
|
||||
// names, job titles, free-text notes, audit details, ...) can contain
|
||||
// attacker- or user-supplied strings. Prefixing with a single quote is the
|
||||
// standard mitigation (OWASP CSV Injection cheat sheet); it forces the cell
|
||||
// to render as text at the cost of a visible leading ' for the rare
|
||||
// legitimate value that starts with one of these characters.
|
||||
export function sanitizeForSpreadsheetCell(text: string): string {
|
||||
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | boolean | null): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
||||
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
|
||||
// makes umlauts render correctly instead of mojibake on open.
|
||||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
||||
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
|
||||
for (const row of rows) {
|
||||
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
|
||||
}
|
||||
return "" + lines.join("\r\n");
|
||||
}
|
||||
|
||||
function parseIsoDate(value: string): Date | null {
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName: string): Promise<Uint8Array> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(sheetName.slice(0, 31));
|
||||
|
||||
sheet.columns = columns.map((c) => ({
|
||||
header: c.header,
|
||||
key: c.header,
|
||||
width: Math.min(40, Math.max(12, c.header.length + 4)),
|
||||
style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined,
|
||||
}));
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: columns.length } };
|
||||
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
|
||||
for (const row of rows) {
|
||||
const record: Record<string, string | number | boolean | Date | null> = {};
|
||||
for (const c of columns) {
|
||||
const value = c.get(row);
|
||||
record[c.header] =
|
||||
c.kind === "date" && typeof value === "string" && value
|
||||
? (parseIsoDate(value) ?? value)
|
||||
: typeof value === "string"
|
||||
? sanitizeForSpreadsheetCell(value)
|
||||
: value;
|
||||
}
|
||||
sheet.addRow(record);
|
||||
}
|
||||
|
||||
const written = await workbook.xlsx.writeBuffer();
|
||||
return new Uint8Array(written);
|
||||
}
|
||||
|
||||
export function exportFilename(base: string, format: "csv" | "xlsx"): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `${base}-${today}.${format}`;
|
||||
}
|
||||
|
||||
export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit {
|
||||
const contentType =
|
||||
format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv; charset=utf-8";
|
||||
return {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
};
|
||||
}
|
||||
127
lib/reports-data.ts
Normal file
127
lib/reports-data.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||
// what "the current view" means — same filters, same stichtag/event-window
|
||||
// rules.
|
||||
export type ReportFilters = {
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
employment?: string;
|
||||
};
|
||||
|
||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
||||
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
||||
|
||||
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
||||
lookups: OrgLookups;
|
||||
divisions: { id: string; name: string }[];
|
||||
locations: { id: string; name: string }[];
|
||||
}> {
|
||||
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||
supabase.from("divisions").select("id, name").order("name"),
|
||||
supabase.from("departments").select("id, name"),
|
||||
supabase.from("teams").select("id, name, department_id"),
|
||||
supabase.from("locations").select("id, name").order("name"),
|
||||
]);
|
||||
|
||||
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
|
||||
return {
|
||||
lookups: {
|
||||
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
|
||||
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
|
||||
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
|
||||
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
|
||||
},
|
||||
divisions: divisions ?? [],
|
||||
locations: locations ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date";
|
||||
|
||||
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
|
||||
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
|
||||
// division/team/location still reflect the employee's *current* assignment.
|
||||
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||
const asOf = filters.asOf || todayIso();
|
||||
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS);
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
|
||||
const { data } = await query;
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = (data ?? []).map((e) => ({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: e.job_title,
|
||||
division_id: e.division_id,
|
||||
team_id: e.team_id,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
contract_type: e.contract_type,
|
||||
entry_date: e.entry_date,
|
||||
exit_date: e.exit_date,
|
||||
weekly_hours: e.weekly_hours,
|
||||
source: e.source,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
}));
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
||||
}
|
||||
|
||||
// Ereignisse: employee_history has no division_id/team_id of its own, so
|
||||
// this joins in the affected employee's *current* org placement (two plain
|
||||
// queries, merged in JS — the hand-written Database type has no relational
|
||||
// embedding metadata for a single nested-select query).
|
||||
//
|
||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
||||
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
||||
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
||||
|
||||
let historyQuery = supabase.from("employee_history").select("employee_id, event_date, event_type, description");
|
||||
if (from) historyQuery = historyQuery.gte("event_date", from);
|
||||
if (to) historyQuery = historyQuery.lte("event_date", to);
|
||||
if (filters.eventType) historyQuery = historyQuery.eq("event_type", filters.eventType);
|
||||
|
||||
const [{ data: history }, { data: employees }] = await Promise.all([
|
||||
historyQuery,
|
||||
supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id"),
|
||||
]);
|
||||
|
||||
const employeeById = new Map((employees ?? []).map((e) => [e.id, e]));
|
||||
const events: ReportEvent[] = [];
|
||||
for (const h of history ?? []) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) continue;
|
||||
if (filters.location && emp.location_id !== filters.location) continue;
|
||||
events.push({
|
||||
employee_id: emp.id,
|
||||
first_name: emp.first_name,
|
||||
last_name: emp.last_name,
|
||||
job_title: emp.job_title,
|
||||
division_id: emp.division_id,
|
||||
team_id: emp.team_id,
|
||||
location_id: emp.location_id,
|
||||
event_date: h.event_date,
|
||||
event_type: h.event_type,
|
||||
description: h.description,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
211
lib/reports.ts
211
lib/reports.ts
@@ -1,12 +1,8 @@
|
||||
export type Measure =
|
||||
| "headcount"
|
||||
| "fte"
|
||||
| "hires"
|
||||
| "exits"
|
||||
| "parttime_rate"
|
||||
| "avg_age"
|
||||
| "avg_tenure"
|
||||
| "female_share";
|
||||
import type { EmploymentStatus, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// ── Bestand (point-in-time snapshot) ──────────────────────────────
|
||||
|
||||
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share";
|
||||
|
||||
export type GroupDimension =
|
||||
| "division"
|
||||
@@ -23,8 +19,6 @@ export type GroupDimension =
|
||||
export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
headcount: "Headcount",
|
||||
fte: "FTE",
|
||||
hires: "Eintritte",
|
||||
exits: "Austritte",
|
||||
parttime_rate: "Teilzeitquote",
|
||||
avg_age: "Ø Alter",
|
||||
avg_tenure: "Ø Zugehörigkeit",
|
||||
@@ -45,7 +39,22 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
};
|
||||
|
||||
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
|
||||
export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"];
|
||||
const SUM_MEASURES: Measure[] = ["headcount", "fte"];
|
||||
|
||||
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
|
||||
export const DEFAULT_STATUSES: EmploymentStatus[] = ["Aktiv", "Karenz"];
|
||||
|
||||
// `status` filters travel through the URL/exports as a comma-joined list
|
||||
// (e.g. "Aktiv,Karenz"); this is the one place that turns that string back
|
||||
// into a validated status set, defaulting to Aktiv+Karenz when unset — used
|
||||
// by both the Bestand pivot and the full data export so they can never
|
||||
// silently disagree on which statuses "no filter" means.
|
||||
export function parseStatuses(status: string | undefined): EmploymentStatus[] {
|
||||
if (!status) return DEFAULT_STATUSES;
|
||||
const requested = status.split(",");
|
||||
const valid = STATUS_OPTIONS.filter((s) => requested.includes(s));
|
||||
return valid.length > 0 ? valid : DEFAULT_STATUSES;
|
||||
}
|
||||
|
||||
export type ReportEmployee = {
|
||||
id: string;
|
||||
@@ -74,17 +83,38 @@ export type OrgLookups = {
|
||||
locationName: Map<string, string>;
|
||||
};
|
||||
|
||||
function ageFromBirthDate(birthDate: string): number {
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Reconstructs status as of any date from the columns that actually carry a
|
||||
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
||||
// which only ever reflects *today*. Division/team/location still reflect the
|
||||
// employee's *current* assignment — the schema has no history of org-unit
|
||||
// changes over time, only free-text employee_history descriptions — so a
|
||||
// stichtag report groups by today's org placement, not the placement as of
|
||||
// that date. Documented in the UI rather than silently wrong.
|
||||
export function deriveStatusAsOf(
|
||||
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
|
||||
asOf: string
|
||||
): EmploymentStatus {
|
||||
if (e.entry_date > asOf) return "Geplant";
|
||||
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten";
|
||||
if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz";
|
||||
return "Aktiv";
|
||||
}
|
||||
|
||||
function ageAsOf(birthDate: string, asOf: string): number {
|
||||
const d = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - d.getFullYear();
|
||||
if (today.getMonth() < d.getMonth() || (today.getMonth() === d.getMonth() && today.getDate() < d.getDate())) age -= 1;
|
||||
const ref = new Date(asOf);
|
||||
let age = ref.getFullYear() - d.getFullYear();
|
||||
if (ref.getMonth() < d.getMonth() || (ref.getMonth() === d.getMonth() && ref.getDate() < d.getDate())) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
function tenureYears(entryDate: string, exitDate: string | null): number {
|
||||
function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
|
||||
const start = new Date(entryDate);
|
||||
const end = exitDate ? new Date(exitDate) : new Date();
|
||||
const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf);
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
|
||||
}
|
||||
|
||||
@@ -115,21 +145,19 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
|
||||
}
|
||||
}
|
||||
|
||||
export function measureValue(rows: ReportEmployee[], measure: Measure): number {
|
||||
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
|
||||
if (rows.length === 0) return 0;
|
||||
switch (measure) {
|
||||
case "headcount":
|
||||
case "hires":
|
||||
case "exits":
|
||||
return rows.length;
|
||||
case "fte":
|
||||
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
|
||||
case "parttime_rate":
|
||||
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
|
||||
case "avg_age":
|
||||
return rows.reduce((s, e) => s + ageFromBirthDate(e.birth_date), 0) / rows.length;
|
||||
return rows.reduce((s, e) => s + ageAsOf(e.birth_date, asOf), 0) / rows.length;
|
||||
case "avg_tenure":
|
||||
return rows.reduce((s, e) => s + tenureYears(e.entry_date, e.exit_date), 0) / rows.length;
|
||||
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
|
||||
case "female_share":
|
||||
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
|
||||
default:
|
||||
@@ -146,7 +174,8 @@ export function aggregateReport(
|
||||
measure: Measure,
|
||||
group: GroupDimension,
|
||||
split: GroupDimension | null,
|
||||
lookups: OrgLookups
|
||||
lookups: OrgLookups,
|
||||
asOf: string = todayIso()
|
||||
): ReportRow[] {
|
||||
const byGroup = new Map<string, ReportEmployee[]>();
|
||||
for (const e of employees) {
|
||||
@@ -157,7 +186,7 @@ export function aggregateReport(
|
||||
|
||||
const rows: ReportRow[] = [];
|
||||
for (const [key, rowsForGroup] of byGroup) {
|
||||
const value = measureValue(rowsForGroup, measure);
|
||||
const value = measureValue(rowsForGroup, measure, asOf);
|
||||
const people: ReportPerson[] = rowsForGroup.map((e) => ({
|
||||
id: e.id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
@@ -175,7 +204,7 @@ export function aggregateReport(
|
||||
}
|
||||
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
|
||||
key: sKey,
|
||||
value: measureValue(sRows, measure),
|
||||
value: measureValue(sRows, measure, asOf),
|
||||
count: sRows.length,
|
||||
}));
|
||||
}
|
||||
@@ -184,11 +213,137 @@ export function aggregateReport(
|
||||
return rows.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
export function sumValues(rows: { value: number }[]): number {
|
||||
return rows.reduce((s, r) => s + r.value, 0);
|
||||
}
|
||||
|
||||
// headcount/fte sum across groups; averages/ratios are weighted by each
|
||||
// group's underlying record count for a sensible overall figure.
|
||||
export function totalForRows(rows: { value: number; count: number }[], measure: Measure): number {
|
||||
if (SUM_MEASURES.includes(measure)) return sumValues(rows);
|
||||
const totalCount = rows.reduce((s, r) => s + r.count, 0);
|
||||
if (totalCount === 0) return 0;
|
||||
return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount;
|
||||
}
|
||||
|
||||
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
|
||||
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
|
||||
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
|
||||
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
|
||||
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
|
||||
{ name: "Eintritte nach Bereich", measure: "hires", group: "division" },
|
||||
{ name: "Austritte nach Abteilung", measure: "exits", group: "department" },
|
||||
];
|
||||
|
||||
// ── Ereignisse (events over a period) ─────────────────────────────
|
||||
// Backed by employee_history, the append-only log — unlike Bestand, this
|
||||
// covers every event type (not just Eintritt/Austritt), survives an
|
||||
// employee's entry_date being overwritten by a later rehire, and each event
|
||||
// keeps its own date/description regardless of the employee's current state.
|
||||
|
||||
// Sentinel for "von"/"bis" — distinct from "" (unset, falls back to the
|
||||
// current-year default) or a real date. Written to the URL/exports as the
|
||||
// literal string "open".
|
||||
export const EVENT_DATE_OPEN = "open";
|
||||
|
||||
export type EventGroupDimension = "event_type" | "division" | "department" | "team" | "location" | "event_year";
|
||||
|
||||
export const EVENT_GROUP_LABELS: Record<EventGroupDimension, string> = {
|
||||
event_type: "Ereignistyp",
|
||||
division: "Bereich",
|
||||
department: "Abteilung",
|
||||
team: "Team",
|
||||
location: "Standort",
|
||||
event_year: "Jahr",
|
||||
};
|
||||
|
||||
export const EVENT_TYPE_LABELS: Record<HistoryEventType, string> = {
|
||||
Eintritt: "Eintritt",
|
||||
Beförderung: "Beförderung",
|
||||
Versetzung: "Versetzung",
|
||||
Karenz: "Karenz",
|
||||
Vertragsänderung: "Vertragsänderung",
|
||||
Stammdatenänderung: "Stammdatenänderung",
|
||||
Austritt: "Austritt",
|
||||
Wiedereintritt: "Wiedereintritt",
|
||||
Reorganisation: "Reorganisation",
|
||||
Gehaltsanpassung: "Gehaltsanpassung",
|
||||
Rückkehr: "Rückkehr (Karenz)",
|
||||
};
|
||||
|
||||
export type ReportEvent = {
|
||||
employee_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
division_id: string;
|
||||
team_id: string | null;
|
||||
location_id: string;
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string {
|
||||
switch (dim) {
|
||||
case "event_type":
|
||||
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
|
||||
case "division":
|
||||
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||
case "department":
|
||||
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
case "team":
|
||||
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "event_year":
|
||||
return String(new Date(e.event_date).getFullYear());
|
||||
default:
|
||||
return "Unbekannt";
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateEvents(
|
||||
events: ReportEvent[],
|
||||
group: EventGroupDimension,
|
||||
split: EventGroupDimension | null,
|
||||
lookups: OrgLookups
|
||||
): ReportRow[] {
|
||||
const byGroup = new Map<string, ReportEvent[]>();
|
||||
for (const e of events) {
|
||||
const key = eventGroupKeyFor(e, group, lookups);
|
||||
if (!byGroup.has(key)) byGroup.set(key, []);
|
||||
byGroup.get(key)!.push(e);
|
||||
}
|
||||
|
||||
const rows: ReportRow[] = [];
|
||||
for (const [key, rowsForGroup] of byGroup) {
|
||||
// Repurposes ReportPerson for events: title -> event description,
|
||||
// entry_date -> event_date. Keeps the existing drill-down UI/export
|
||||
// code working unchanged for both report modes.
|
||||
const people: ReportPerson[] = rowsForGroup.map((e) => ({
|
||||
id: e.employee_id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
title: e.description,
|
||||
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||
entry_date: e.event_date,
|
||||
}));
|
||||
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
|
||||
if (split) {
|
||||
const bySplit = new Map<string, ReportEvent[]>();
|
||||
for (const e of rowsForGroup) {
|
||||
const sKey = eventGroupKeyFor(e, split, lookups);
|
||||
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
|
||||
bySplit.get(sKey)!.push(e);
|
||||
}
|
||||
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ key: sKey, value: sRows.length, count: sRows.length }));
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; split?: EventGroupDimension; eventType?: HistoryEventType }[] = [
|
||||
{ name: "Ereignisse nach Typ", group: "event_type" },
|
||||
{ name: "Eintritte nach Bereich", group: "division", eventType: "Eintritt" },
|
||||
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
|
||||
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
|
||||
];
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import "server-only";
|
||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
||||
// from a Client Component or anything bundled for the browser.
|
||||
// from a Client Component or anything bundled for the browser. The
|
||||
// "server-only" import makes an accidental client-side import a build error
|
||||
// instead of a runtime one.
|
||||
export function createAdminClient() {
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error("createAdminClient must never be called in the browser");
|
||||
}
|
||||
|
||||
return createSupabaseClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
|
||||
9
lib/supabase/query.ts
Normal file
9
lib/supabase/query.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
|
||||
// delimiters between conditions. A raw user-supplied search term containing
|
||||
// them (e.g. from a search box or ?q= param) can break out of the intended
|
||||
// column conditions and append arbitrary extra filters to the query. Strip
|
||||
// them before interpolating — harmless for real name/title searches, which
|
||||
// never legitimately contain them.
|
||||
export function sanitizeIlikeTerm(term: string): string {
|
||||
return term.replace(/[,()]/g, "");
|
||||
}
|
||||
Reference in New Issue
Block a user