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:
2026-07-24 23:38:10 +02:00
parent f96773da0f
commit 79f0e19bf8
101 changed files with 6120 additions and 700 deletions

View File

@@ -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",
};

View File

@@ -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 {

View File

@@ -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
View 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
View 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 };
}

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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
View 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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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.
}
},
},
});
}

View File

@@ -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
View 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"];