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,4 @@
|
||||
import type { EmploymentStatus } from "./supabase/types";
|
||||
import type { EmploymentStatus, NoteCategory } from "./supabase/types";
|
||||
|
||||
const AVATAR_PALETTE = [
|
||||
"#d6046e",
|
||||
@@ -30,7 +30,7 @@ export const STATUS_STYLES: Record<EmploymentStatus, string> = {
|
||||
|
||||
type ColorCategory = "success" | "danger" | "warning" | "info" | "purple" | "brand";
|
||||
|
||||
const CATEGORY_STYLES: Record<ColorCategory, string> = {
|
||||
export const CATEGORY_STYLES: Record<ColorCategory, string> = {
|
||||
success: "bg-success-bg text-success-text",
|
||||
danger: "bg-danger-bg text-danger-text",
|
||||
warning: "bg-warning-bg text-warning-text",
|
||||
@@ -61,3 +61,11 @@ const ACTION_CATEGORY: Record<string, ColorCategory> = {
|
||||
export function actionBadgeStyle(action: string): string {
|
||||
return CATEGORY_STYLES[ACTION_CATEGORY[action] ?? "brand"];
|
||||
}
|
||||
|
||||
export const NOTE_CATEGORY_STYLES: Record<NoteCategory, string> = {
|
||||
Vertraulich: CATEGORY_STYLES.purple,
|
||||
"Personalgespräch": CATEGORY_STYLES.info,
|
||||
Wiedervorlage: CATEGORY_STYLES.warning,
|
||||
"Lob / Anerkennung": CATEGORY_STYLES.success,
|
||||
Allgemein: "bg-surface text-ink-muted",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
// This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as
|
||||
// timestamptz, and is used from a single timezone. Every conversion here is
|
||||
// pinned to Europe/Vienna rather than the runtime's zone: the server renders
|
||||
// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned
|
||||
// formatter produces a different day on each side — wrong dates for the user
|
||||
// near midnight, and a React hydration mismatch.
|
||||
const TIMEZONE = "Europe/Vienna";
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: TIMEZONE,
|
||||
});
|
||||
|
||||
// en-CA formats as "YYYY-MM-DD", which is the shape the rest of the app and
|
||||
// the database speak.
|
||||
const isoFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: TIMEZONE,
|
||||
});
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// Today in Vienna. Deliberately not new Date().toISOString().slice(0, 10):
|
||||
// that is the *UTC* date, which for part of every day is already tomorrow
|
||||
// relative to Austria.
|
||||
export function todayIso(): string {
|
||||
return isoFormatter.format(new Date());
|
||||
}
|
||||
|
||||
// Normalizes either input to a "YYYY-MM-DD" string. A date-only string is
|
||||
// returned as-is — parsing it into a Date first would anchor it to UTC
|
||||
// midnight and shift it in any negative-offset zone.
|
||||
export function toIsoDate(value: string | Date): string {
|
||||
if (typeof value === "string") return DATE_ONLY.test(value) ? value : isoFormatter.format(new Date(value));
|
||||
return isoFormatter.format(value);
|
||||
}
|
||||
|
||||
export function fmtDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return "–";
|
||||
if (typeof date === "string" && DATE_ONLY.test(date)) {
|
||||
const [year, month, day] = date.split("-");
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
if (Number.isNaN(d.getTime())) return "–";
|
||||
return dateFormatter.format(d);
|
||||
return dateTimeFormatter.format(d);
|
||||
}
|
||||
|
||||
export function initials(firstName: string, lastName: string): string {
|
||||
@@ -17,24 +56,37 @@ export function initials(firstName: string, lastName: string): string {
|
||||
return `${a}${b}`;
|
||||
}
|
||||
|
||||
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
|
||||
// titles follow after a comma, both space-joined in the stored order.
|
||||
export function fmtFullName(
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
titlePrefix: string[] | null | undefined,
|
||||
titleSuffix: string[] | null | undefined
|
||||
): string {
|
||||
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
|
||||
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
|
||||
return `${prefix}${firstName} ${lastName}${suffix}`;
|
||||
}
|
||||
|
||||
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,
|
||||
// which is exact and needs no Date arithmetic at all.
|
||||
export function yearsBetweenIso(from: string, to: string): number {
|
||||
const years = Number(to.slice(0, 4)) - Number(from.slice(0, 4));
|
||||
return to.slice(5) < from.slice(5) ? years - 1 : years;
|
||||
}
|
||||
|
||||
export function fmtAge(birthDate: string | Date): number {
|
||||
const d = typeof birthDate === "string" ? new Date(birthDate) : birthDate;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - d.getFullYear();
|
||||
const hasHadBirthdayThisYear =
|
||||
today.getMonth() > d.getMonth() ||
|
||||
(today.getMonth() === d.getMonth() && today.getDate() >= d.getDate());
|
||||
if (!hasHadBirthdayThisYear) age -= 1;
|
||||
return age;
|
||||
return yearsBetweenIso(toIsoDate(birthDate), todayIso());
|
||||
}
|
||||
|
||||
export function tenure(entryDate: string | Date, endDate?: string | Date | null): string {
|
||||
const start = typeof entryDate === "string" ? new Date(entryDate) : entryDate;
|
||||
const end = endDate ? (typeof endDate === "string" ? new Date(endDate) : endDate) : new Date();
|
||||
const start = toIsoDate(entryDate);
|
||||
const end = endDate ? toIsoDate(endDate) : todayIso();
|
||||
|
||||
let years = end.getFullYear() - start.getFullYear();
|
||||
let months = end.getMonth() - start.getMonth();
|
||||
if (end.getDate() < start.getDate()) months -= 1;
|
||||
let years = Number(end.slice(0, 4)) - Number(start.slice(0, 4));
|
||||
let months = Number(end.slice(5, 7)) - Number(start.slice(5, 7));
|
||||
if (Number(end.slice(8, 10)) < Number(start.slice(8, 10))) months -= 1;
|
||||
if (months < 0) {
|
||||
years -= 1;
|
||||
months += 12;
|
||||
@@ -47,8 +99,16 @@ export function tenure(entryDate: string | Date, endDate?: string | Date | null)
|
||||
return yearPart || monthPart || "unter 1 Monat";
|
||||
}
|
||||
|
||||
export function daysBetween(a: string | Date, b: string | Date = new Date()): number {
|
||||
const start = typeof a === "string" ? new Date(a) : a;
|
||||
const end = typeof b === "string" ? new Date(b) : b;
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
// Anchored at UTC midnight on both sides so the difference is a whole number
|
||||
// of calendar days regardless of DST transitions in between.
|
||||
export function daysBetweenIso(from: string, to: string = todayIso()): number {
|
||||
const start = Date.parse(`${from}T00:00:00Z`);
|
||||
const end = Date.parse(`${to}T00:00:00Z`);
|
||||
return Math.round((end - start) / 86_400_000);
|
||||
}
|
||||
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
26
lib/notes.ts
Normal file
26
lib/notes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||
employeeName: string;
|
||||
};
|
||||
|
||||
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
|
||||
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
|
||||
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
|
||||
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
|
||||
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
|
||||
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
|
||||
const [{ data: notes }, employees] = await Promise.all([
|
||||
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||
]);
|
||||
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
return (notes ?? []).map((n) => {
|
||||
const emp = employeeById.get(n.employee_id);
|
||||
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
|
||||
});
|
||||
}
|
||||
212
lib/orgchart-data.ts
Normal file
212
lib/orgchart-data.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { OrgEmployee } from "@/components/orgchart/types";
|
||||
import { todayIso } from "./format";
|
||||
import { deriveStatusAsOf } from "./reports";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
// The Organigramm as it stood (or will stand) on a given date. Three sources
|
||||
// have to be reconciled, because no single one covers the whole timeline:
|
||||
//
|
||||
// past/today employee_assignments — the interval covering `asOf`
|
||||
// future pending_org_changes — effective-dated moves not yet applied
|
||||
// membership entry/exit/karenz — who counted as staff on that date
|
||||
//
|
||||
// See supabase/migrations/*_employee_assignment_history.sql for why the
|
||||
// placement timeline is captured by a trigger rather than per-RPC.
|
||||
|
||||
const ORG_COLUMNS =
|
||||
"id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date";
|
||||
|
||||
/** Change types that move someone in the org; the rest only affect status or contract. */
|
||||
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
|
||||
|
||||
export type OrgAsOfResult = {
|
||||
employees: OrgEmployee[];
|
||||
/** How many placements were projected from not-yet-applied changes. */
|
||||
projectedCount: number;
|
||||
/** Earliest date the assignment history actually covers. */
|
||||
historyStartsAt: string | null;
|
||||
};
|
||||
|
||||
type EmployeeRow = {
|
||||
id: string;
|
||||
personnel_number: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
manager_id: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
entry_date: string;
|
||||
exit_date: string | null;
|
||||
karenz_start_date: string | null;
|
||||
karenz_return_date: string | null;
|
||||
};
|
||||
|
||||
type AssignmentRow = {
|
||||
employee_id: string;
|
||||
manager_id: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
job_title: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
valid_from: string;
|
||||
};
|
||||
|
||||
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
||||
|
||||
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
|
||||
|
||||
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
|
||||
// their team's lead, a team lead to the division head, and anyone without a
|
||||
// team to the CEO. Only used for employees a pending change actually moves —
|
||||
// everyone else keeps the manager recorded on their assignment, so existing
|
||||
// data that deviates from the rule is never silently "corrected".
|
||||
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
|
||||
if (placement.team_id && !placement.is_lead) {
|
||||
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
|
||||
}
|
||||
if (placement.team_id && placement.is_lead) {
|
||||
return (
|
||||
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
||||
const today = todayIso();
|
||||
|
||||
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
|
||||
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("employee_assignments")
|
||||
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
|
||||
.lte("valid_from", asOf)
|
||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
||||
.order("employee_id")
|
||||
),
|
||||
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
|
||||
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
|
||||
asOf > today
|
||||
? fetchAllRows(() =>
|
||||
supabase
|
||||
.from("pending_org_changes")
|
||||
.select("employee_id, change_type, effective_date, payload")
|
||||
.eq("status", "pending")
|
||||
.lte("effective_date", asOf)
|
||||
.in("change_type", [...PLACEMENT_CHANGES])
|
||||
.order("effective_date")
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
|
||||
}
|
||||
|
||||
// The pure half of the above: everything that turns the four row sets into a
|
||||
// snapshot, with no Supabase client in sight, so the reconciliation rules can
|
||||
// be tested directly.
|
||||
export function resolveOrgSnapshot({
|
||||
asOf,
|
||||
employees: allEmployees,
|
||||
assignments,
|
||||
teams,
|
||||
departments,
|
||||
pending,
|
||||
}: {
|
||||
asOf: string;
|
||||
employees: EmployeeRow[];
|
||||
assignments: AssignmentRow[];
|
||||
teams: { id: string; department_id: string }[];
|
||||
departments: { id: string; division_id: string }[];
|
||||
pending: PendingRow[];
|
||||
}): OrgAsOfResult {
|
||||
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
|
||||
// A projected move names only the target team; its division follows from
|
||||
// the team's department, the same way the DB trigger derives it.
|
||||
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||||
const teamDivision = new Map(
|
||||
teams.flatMap((t) => {
|
||||
const divisionId = departmentDivision.get(t.department_id);
|
||||
return divisionId ? [[t.id, divisionId] as const] : [];
|
||||
})
|
||||
);
|
||||
|
||||
// Employed (or on leave) on that date — the same derivation the Berichte
|
||||
// page uses, so the two can never disagree on who counted when.
|
||||
const staff = allEmployees.filter((e) => {
|
||||
const status = deriveStatusAsOf(e, asOf);
|
||||
return status === "Aktiv" || status === "Karenz";
|
||||
});
|
||||
|
||||
const resolved = staff.map((e) => {
|
||||
const a = assignmentByEmployee.get(e.id);
|
||||
return {
|
||||
employee: e,
|
||||
managerId: a ? a.manager_id : e.manager_id,
|
||||
placement: {
|
||||
team_id: a ? a.team_id : e.team_id,
|
||||
division_id: a ? a.division_id : e.division_id,
|
||||
job_title: a ? a.job_title : e.job_title,
|
||||
is_lead: a ? a.is_lead : e.is_lead,
|
||||
org_level: a ? a.org_level : e.org_level,
|
||||
} satisfies Placement,
|
||||
};
|
||||
});
|
||||
|
||||
// Project the future. Ordered by effective_date, so a later move wins.
|
||||
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
|
||||
const moved = new Set<string>();
|
||||
for (const change of pending) {
|
||||
const target = byId.get(change.employee_id);
|
||||
if (!target) continue;
|
||||
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
|
||||
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
|
||||
if (newTeamId) {
|
||||
target.placement.team_id = newTeamId;
|
||||
const divisionId = teamDivision.get(newTeamId);
|
||||
if (divisionId) target.placement.division_id = divisionId;
|
||||
moved.add(target.employee.id);
|
||||
}
|
||||
if (payload.new_title) target.placement.job_title = payload.new_title;
|
||||
}
|
||||
|
||||
// Second pass: a moved employee's manager follows from the *projected*
|
||||
// org, not the one they left — and the lead of their new team may itself
|
||||
// have moved in this same batch.
|
||||
for (const r of resolved) {
|
||||
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
|
||||
}
|
||||
|
||||
// A manager who had not joined yet, or had already left, is not in this
|
||||
// set — without re-rooting, their whole reporting line would silently
|
||||
// vanish from the chart rather than showing up one level higher.
|
||||
const presentIds = new Set(resolved.map((r) => r.employee.id));
|
||||
|
||||
const employees: OrgEmployee[] = resolved.map((r) => ({
|
||||
id: r.employee.id,
|
||||
personnel_number: r.employee.personnel_number,
|
||||
first_name: r.employee.first_name,
|
||||
last_name: r.employee.last_name,
|
||||
job_title: r.placement.job_title,
|
||||
manager_id: r.managerId && presentIds.has(r.managerId) ? r.managerId : null,
|
||||
team_id: r.placement.team_id,
|
||||
division_id: r.placement.division_id,
|
||||
is_lead: r.placement.is_lead,
|
||||
org_level: r.placement.org_level,
|
||||
}));
|
||||
|
||||
const historyStartsAt = assignments.reduce<string | null>(
|
||||
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
|
||||
null
|
||||
);
|
||||
|
||||
return { employees, projectedCount: moved.size, historyStartsAt };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type OpenPositionResolved = {
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
managerName: string | null;
|
||||
orgLabel: string;
|
||||
@@ -20,7 +21,7 @@ export async function loadOpenPositions(supabase: SupabaseClient<Database>): Pro
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
const { data: positions } = await supabase
|
||||
.from("positions")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, created_at")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||
@@ -41,7 +42,19 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
|
||||
}
|
||||
|
||||
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";
|
||||
"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, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
||||
|
||||
// employee_id -> number of employee_dependents rows. Selects only the FK
|
||||
// column (no dependent PII needed) since only per-employee counts feed the
|
||||
// has_dependents/avg_dependents report dimensions; counted client-side
|
||||
// since the Supabase JS client has no `count(*) group by employee_id`
|
||||
// shorthand. Shared by the Bestand pivot and the full employees export.
|
||||
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
|
||||
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
|
||||
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
|
||||
@@ -49,14 +62,17 @@ const SNAPSHOT_EMPLOYEE_COLUMNS =
|
||||
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);
|
||||
function snapshotQuery() {
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
||||
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);
|
||||
return query;
|
||||
}
|
||||
|
||||
const { data } = await query;
|
||||
const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = (data ?? []).map((e) => ({
|
||||
const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
@@ -74,6 +90,14 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
worker_type: e.worker_type,
|
||||
collective_agreement: e.collective_agreement,
|
||||
work_days: e.work_days,
|
||||
is_betriebsrat: e.is_betriebsrat,
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
}));
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
@@ -93,19 +117,22 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
|
||||
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);
|
||||
function historyQuery() {
|
||||
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
||||
if (from) query = query.gte("event_date", from);
|
||||
if (to) query = query.lte("event_date", to);
|
||||
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
||||
return query;
|
||||
}
|
||||
|
||||
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 [history, employees] = await Promise.all([
|
||||
fetchAllRows(historyQuery),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||
]);
|
||||
|
||||
const employeeById = new Map((employees ?? []).map((e) => [e.id, e]));
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
const events: ReportEvent[] = [];
|
||||
for (const h of history ?? []) {
|
||||
for (const h of history) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) continue;
|
||||
|
||||
191
lib/reports.ts
191
lib/reports.ts
@@ -1,8 +1,11 @@
|
||||
import type { EmploymentStatus, HistoryEventType } from "./supabase/types";
|
||||
import { todayIso, yearsBetweenIso } from "./format";
|
||||
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types";
|
||||
|
||||
export { todayIso };
|
||||
|
||||
// ── Bestand (point-in-time snapshot) ──────────────────────────────
|
||||
|
||||
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share";
|
||||
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share" | "avg_dependents";
|
||||
|
||||
export type GroupDimension =
|
||||
| "division"
|
||||
@@ -14,7 +17,15 @@ export type GroupDimension =
|
||||
| "contract_type"
|
||||
| "entry_year"
|
||||
| "source"
|
||||
| "paygrade";
|
||||
| "paygrade"
|
||||
| "worker_type"
|
||||
| "collective_agreement"
|
||||
| "betriebsrat"
|
||||
| "dienstwagen"
|
||||
| "laterale_fuehrung"
|
||||
| "c_level"
|
||||
| "has_dependents"
|
||||
| "weekday";
|
||||
|
||||
export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
headcount: "Headcount",
|
||||
@@ -23,6 +34,7 @@ export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
avg_age: "Ø Alter",
|
||||
avg_tenure: "Ø Zugehörigkeit",
|
||||
female_share: "Frauenanteil",
|
||||
avg_dependents: "Ø Angehörige",
|
||||
};
|
||||
|
||||
export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
@@ -36,9 +48,17 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
entry_year: "Eintrittsjahr",
|
||||
source: "Intern/Extern",
|
||||
paygrade: "Paygrade",
|
||||
worker_type: "Angestellte:r / Arbeiter:in",
|
||||
collective_agreement: "Kollektivvertrag",
|
||||
betriebsrat: "Betriebsrat",
|
||||
dienstwagen: "Dienstwagen",
|
||||
laterale_fuehrung: "Laterale Führung",
|
||||
c_level: "C-Level",
|
||||
has_dependents: "Hat Angehörige",
|
||||
weekday: "Wochentag",
|
||||
};
|
||||
|
||||
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
|
||||
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share", "avg_dependents"];
|
||||
const SUM_MEASURES: Measure[] = ["headcount", "fte"];
|
||||
|
||||
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
|
||||
@@ -74,6 +94,14 @@ export type ReportEmployee = {
|
||||
paygrade: string;
|
||||
birth_date: string;
|
||||
gender: string;
|
||||
worker_type: string;
|
||||
collective_agreement: string;
|
||||
work_days: Weekday[];
|
||||
is_betriebsrat: boolean;
|
||||
has_dienstwagen: boolean;
|
||||
is_laterale_fuehrung: boolean;
|
||||
is_c_level: boolean;
|
||||
dependents_count: number;
|
||||
};
|
||||
|
||||
export type OrgLookups = {
|
||||
@@ -83,10 +111,6 @@ export type OrgLookups = {
|
||||
locationName: Map<string, string>;
|
||||
};
|
||||
|
||||
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
|
||||
@@ -104,18 +128,10 @@ export function deriveStatusAsOf(
|
||||
return "Aktiv";
|
||||
}
|
||||
|
||||
function ageAsOf(birthDate: string, asOf: string): number {
|
||||
const d = new Date(birthDate);
|
||||
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 tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
|
||||
const start = new Date(entryDate);
|
||||
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));
|
||||
const start = Date.parse(`${entryDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`);
|
||||
return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25));
|
||||
}
|
||||
|
||||
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
|
||||
@@ -135,16 +151,62 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
|
||||
case "contract_type":
|
||||
return e.contract_type;
|
||||
case "entry_year":
|
||||
return String(new Date(e.entry_date).getFullYear());
|
||||
return e.entry_date.slice(0, 4);
|
||||
case "source":
|
||||
return e.source;
|
||||
case "paygrade":
|
||||
return e.paygrade;
|
||||
case "worker_type":
|
||||
return e.worker_type;
|
||||
case "collective_agreement":
|
||||
return e.collective_agreement;
|
||||
case "betriebsrat":
|
||||
return e.is_betriebsrat ? "Ja" : "Nein";
|
||||
case "dienstwagen":
|
||||
return e.has_dienstwagen ? "Ja" : "Nein";
|
||||
case "laterale_fuehrung":
|
||||
return e.is_laterale_fuehrung ? "Ja" : "Nein";
|
||||
case "c_level":
|
||||
return e.is_c_level ? "Ja" : "Nein";
|
||||
case "has_dependents":
|
||||
return e.dependents_count > 0 ? "Ja" : "Nein";
|
||||
case "weekday":
|
||||
// Not a strict partition — see groupKeysFor, which aggregateReport
|
||||
// actually uses. This single-key fallback only covers a direct
|
||||
// groupKeyFor("weekday", ...) call from outside aggregateReport.
|
||||
return e.work_days[0] ?? "–";
|
||||
default:
|
||||
return "Unbekannt";
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function weekdayRank(key: string): number {
|
||||
const i = WEEKDAY_ORDER.indexOf(key as Weekday);
|
||||
return i === -1 ? WEEKDAY_ORDER.length : i;
|
||||
}
|
||||
|
||||
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
|
||||
}
|
||||
|
||||
// Reused by ReportsPageClient (split legend) and the report export route
|
||||
// (split columns) to render a `weekday` split chronologically rather than
|
||||
// in first-encountered order; a no-op for every other dimension.
|
||||
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
|
||||
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
|
||||
}
|
||||
|
||||
// Every dimension other than `weekday` is a strict single-key partition
|
||||
// (delegates to groupKeyFor); `weekday` returns one key per work day, so an
|
||||
// employee is counted in every day they work — deliberately not a
|
||||
// partition, since that's the whole point of the dimension.
|
||||
export function groupKeysFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string[] {
|
||||
if (dim === "weekday") return e.work_days.length > 0 ? e.work_days : ["–"];
|
||||
return [groupKeyFor(e, dim, lookups)];
|
||||
}
|
||||
|
||||
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
|
||||
if (rows.length === 0) return 0;
|
||||
switch (measure) {
|
||||
@@ -155,11 +217,13 @@ export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: str
|
||||
case "parttime_rate":
|
||||
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
|
||||
case "avg_age":
|
||||
return rows.reduce((s, e) => s + ageAsOf(e.birth_date, asOf), 0) / rows.length;
|
||||
return rows.reduce((s, e) => s + yearsBetweenIso(e.birth_date, asOf), 0) / rows.length;
|
||||
case "avg_tenure":
|
||||
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;
|
||||
case "avg_dependents":
|
||||
return rows.reduce((s, e) => s + e.dependents_count, 0) / rows.length;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -179,9 +243,10 @@ export function aggregateReport(
|
||||
): ReportRow[] {
|
||||
const byGroup = new Map<string, ReportEmployee[]>();
|
||||
for (const e of employees) {
|
||||
const key = groupKeyFor(e, group, lookups);
|
||||
if (!byGroup.has(key)) byGroup.set(key, []);
|
||||
byGroup.get(key)!.push(e);
|
||||
for (const key of groupKeysFor(e, group, lookups)) {
|
||||
if (!byGroup.has(key)) byGroup.set(key, []);
|
||||
byGroup.get(key)!.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ReportRow[] = [];
|
||||
@@ -198,19 +263,21 @@ export function aggregateReport(
|
||||
if (split) {
|
||||
const bySplit = new Map<string, ReportEmployee[]>();
|
||||
for (const e of rowsForGroup) {
|
||||
const sKey = groupKeyFor(e, split, lookups);
|
||||
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
|
||||
bySplit.get(sKey)!.push(e);
|
||||
for (const sKey of groupKeysFor(e, split, lookups)) {
|
||||
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
|
||||
bySplit.get(sKey)!.push(e);
|
||||
}
|
||||
}
|
||||
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
|
||||
const splitRows = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
|
||||
key: sKey,
|
||||
value: measureValue(sRows, measure, asOf),
|
||||
count: sRows.length,
|
||||
}));
|
||||
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows.sort((a, b) => b.value - a.value);
|
||||
return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
export function sumValues(rows: { value: number }[]): number {
|
||||
@@ -231,6 +298,9 @@ export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimen
|
||||
{ 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: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
|
||||
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },
|
||||
{ name: "Ø Angehörige nach Bereich", measure: "avg_dependents", group: "division" },
|
||||
];
|
||||
|
||||
// ── Ereignisse (events over a period) ─────────────────────────────
|
||||
@@ -295,7 +365,7 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "event_year":
|
||||
return String(new Date(e.event_date).getFullYear());
|
||||
return e.event_date.slice(0, 4);
|
||||
default:
|
||||
return "Unbekannt";
|
||||
}
|
||||
@@ -347,3 +417,64 @@ export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; s
|
||||
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
|
||||
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
|
||||
];
|
||||
|
||||
// ── Query-string parsing ──────────────────────────────────────────
|
||||
// The Berichte page and every /api/export/* route read the same handful of
|
||||
// dimension/measure names out of a URL the user fully controls. These used
|
||||
// to be unchecked `as` casts, which let an unknown value through as a real
|
||||
// enum value: it reached GROUP_LABELS[group] as undefined (a literal
|
||||
// "undefined" column header in the export) and was interpolated into the
|
||||
// download filename, i.e. into a Content-Disposition header. Parsing against
|
||||
// the label maps — the same objects that define the legal values — keeps the
|
||||
// two in step by construction.
|
||||
|
||||
function parseKeyOf<T extends string>(labels: Record<T, string>, value: string | null | undefined, fallback: T): T {
|
||||
return value && Object.hasOwn(labels, value) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
export function parseMode(value: string | null | undefined): "snapshot" | "events" {
|
||||
return value === "events" ? "events" : "snapshot";
|
||||
}
|
||||
|
||||
export function parseMeasure(value: string | null | undefined): Measure {
|
||||
return parseKeyOf(MEASURE_LABELS, value, "headcount");
|
||||
}
|
||||
|
||||
export function parseGroupDimension(value: string | null | undefined, fallback: GroupDimension = "division"): GroupDimension {
|
||||
return parseKeyOf(GROUP_LABELS, value, fallback);
|
||||
}
|
||||
|
||||
export function parseEventGroupDimension(
|
||||
value: string | null | undefined,
|
||||
fallback: EventGroupDimension = "event_type"
|
||||
): EventGroupDimension {
|
||||
return parseKeyOf(EVENT_GROUP_LABELS, value, fallback);
|
||||
}
|
||||
|
||||
// Unlike the dimensions above, "no split" and "all event types" are legal —
|
||||
// hence null rather than a fallback value for an unrecognized input.
|
||||
export function parseSplitDimension(value: string | null | undefined): GroupDimension | null {
|
||||
return value && Object.hasOwn(GROUP_LABELS, value) ? (value as GroupDimension) : null;
|
||||
}
|
||||
|
||||
export function parseEventSplitDimension(value: string | null | undefined): EventGroupDimension | null {
|
||||
return value && Object.hasOwn(EVENT_GROUP_LABELS, value) ? (value as EventGroupDimension) : null;
|
||||
}
|
||||
|
||||
export function parseEventType(value: string | null | undefined): HistoryEventType | null {
|
||||
return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null;
|
||||
}
|
||||
|
||||
// Rejects anything that is not a real calendar date, so a Stichtag from the
|
||||
// URL can never reach a date comparison (or a column header) as free text.
|
||||
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value ? undefined : value;
|
||||
}
|
||||
|
||||
// from/to additionally accept the EVENT_DATE_OPEN sentinel ("this side of
|
||||
// the interval is intentionally unbounded"), which is not a date.
|
||||
export function parseEventDateParam(value: string | null | undefined): string | undefined {
|
||||
return value === EVENT_DATE_OPEN ? EVENT_DATE_OPEN : parseIsoDateParam(value);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,17 @@ import type { Database } from "./types";
|
||||
// "server-only" import makes an accidental client-side import a build error
|
||||
// instead of a runtime one.
|
||||
export function createAdminClient() {
|
||||
return createSupabaseClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||
);
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!serviceRoleKey) {
|
||||
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
||||
}
|
||||
|
||||
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
|
||||
21
lib/supabase/auth.ts
Normal file
21
lib/supabase/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,9 +3,17 @@ import type { Database } from "./types";
|
||||
|
||||
// For use in Client Components that need interactivity (filters, live
|
||||
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
||||
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
|
||||
export function createClient() {
|
||||
return createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
|
||||
}
|
||||
|
||||
@@ -7,3 +7,27 @@
|
||||
export function sanitizeIlikeTerm(term: string): string {
|
||||
return term.replace(/[,()]/g, "");
|
||||
}
|
||||
|
||||
// PostgREST caps every response at db.max_rows (1000, see
|
||||
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
||||
// or the employee_history log just stops returning rows, and a report or
|
||||
// export built from it is quietly wrong rather than failing. Anything that
|
||||
// aggregates a whole table has to page explicitly; anything that renders a
|
||||
// bounded list (an employee page, the audit log) uses .range() directly and
|
||||
// does not need this.
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
type PagedQuery<Row> = {
|
||||
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
||||
};
|
||||
|
||||
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; ; page++) {
|
||||
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
||||
if (error || !data) break;
|
||||
rows.push(...data);
|
||||
if (data.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import "server-only";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// For use in Server Components and Server Actions. Respects the signed-in
|
||||
// user's session, so all reads/writes go through RLS as that user.
|
||||
// user's session, so all reads/writes go through RLS as that user. Uses only
|
||||
// the anon key (never the service role key) — the user's own session cookie
|
||||
// is what determines access, via RLS.
|
||||
export async function createClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component during render — safe to ignore
|
||||
// because proxy.ts refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
}
|
||||
);
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component during render — safe to ignore
|
||||
// because proxy.ts refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export type ContractType = "unbefristet" | "befristet";
|
||||
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
export type SourceType = "Intern" | "Extern";
|
||||
export type GenderType = "m" | "w";
|
||||
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
||||
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
||||
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
||||
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
|
||||
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
||||
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
||||
// union (not a string literal) so a future hr_admin/hr_user split, if ever
|
||||
// technically required, is a type-level addition, not a rewrite.
|
||||
@@ -107,6 +112,8 @@ export type Database = {
|
||||
sv_nummer: string | null;
|
||||
nationality: string;
|
||||
address: string | null;
|
||||
postal_code: string | null;
|
||||
city: string | null;
|
||||
address_country: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
@@ -132,6 +139,15 @@ export type Database = {
|
||||
karenz_start_date: string | null;
|
||||
karenz_return_date: string | null;
|
||||
avatar_color: string | null;
|
||||
worker_type: WorkerType;
|
||||
collective_agreement: CollectiveAgreement;
|
||||
work_days: Weekday[];
|
||||
is_betriebsrat: boolean;
|
||||
has_dienstwagen: boolean;
|
||||
is_laterale_fuehrung: boolean;
|
||||
is_c_level: boolean;
|
||||
title_prefix: string[];
|
||||
title_suffix: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -144,6 +160,8 @@ export type Database = {
|
||||
sv_nummer?: string | null;
|
||||
nationality?: string;
|
||||
address?: string | null;
|
||||
postal_code?: string | null;
|
||||
city?: string | null;
|
||||
address_country?: string | null;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
@@ -167,6 +185,15 @@ export type Database = {
|
||||
karenz_start_date?: string | null;
|
||||
karenz_return_date?: string | null;
|
||||
avatar_color?: string | null;
|
||||
worker_type?: WorkerType;
|
||||
collective_agreement?: CollectiveAgreement;
|
||||
work_days?: Weekday[];
|
||||
is_betriebsrat?: boolean;
|
||||
has_dienstwagen?: boolean;
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
title_prefix?: string[];
|
||||
title_suffix?: string[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
@@ -193,6 +220,58 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
};
|
||||
employee_dependents: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
relationship: RelationshipType;
|
||||
sv_nummer: string | null;
|
||||
birth_date: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
employee_id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
relationship: RelationshipType;
|
||||
sv_nummer?: string | null;
|
||||
birth_date: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_dependents"]["Insert"]>;
|
||||
};
|
||||
employee_notes: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
author_user_id: string | null;
|
||||
author_name: string;
|
||||
category: NoteCategory;
|
||||
note_text: string;
|
||||
due_date: string | null;
|
||||
done: boolean;
|
||||
done_at: string | null;
|
||||
done_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
employee_id: string;
|
||||
author_user_id?: string | null;
|
||||
author_name: string;
|
||||
category?: NoteCategory;
|
||||
note_text: string;
|
||||
due_date?: string | null;
|
||||
done?: boolean;
|
||||
done_at?: string | null;
|
||||
done_by?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
||||
};
|
||||
positions: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -203,6 +282,7 @@ export type Database = {
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: PositionStatus;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
filled_at: string | null;
|
||||
filled_by_employee_id: string | null;
|
||||
@@ -216,6 +296,7 @@ export type Database = {
|
||||
is_lead?: boolean;
|
||||
reports_to_employee_id?: string | null;
|
||||
status?: PositionStatus;
|
||||
valid_from?: string;
|
||||
created_at?: string;
|
||||
filled_at?: string | null;
|
||||
filled_by_employee_id?: string | null;
|
||||
@@ -310,6 +391,25 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||
// read access only, hence no Insert/Update shapes worth modelling.
|
||||
employee_assignments: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
manager_id: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
job_title: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: never;
|
||||
Update: never;
|
||||
};
|
||||
};
|
||||
Views: Record<string, never>;
|
||||
Functions: {
|
||||
@@ -322,7 +422,12 @@ export type Database = {
|
||||
record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
|
||||
8
lib/titles.ts
Normal file
8
lib/titles.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Standard Austrian academic/professional titles (Bundeskanzleramt/ELDA
|
||||
// convention): vorangestellte akademische Grade (prefix, precede the name)
|
||||
// and nachgestellte akademische Grade (suffix, mostly Bologna-system
|
||||
// Bachelor's/Master's degrees, follow the name after a comma). A person can
|
||||
// hold several of either.
|
||||
export const TITLE_PREFIXES: string[] = ["Dr.", "DDr.", "Dipl.-Ing.", "Ing.", "Mag.", "Mag. (FH)", "MMag.", "Dkfm.", "Priv.-Doz.", "Prof."];
|
||||
|
||||
export const TITLE_SUFFIXES: string[] = ["BA", "BSc", "BEd", "BBA", "LLB", "MA", "MSc", "MBA", "MEd", "LLM", "PhD", "MBL"];
|
||||
Reference in New Issue
Block a user