Anforderung 9 aus dem Workshop. Migration 20260915140000.
Im Dokument standen die beiden untereinander:
Arbeiter, Angestellte, Lehrlinge
Standard / Praktikant / Geringfuegige Beschaeftigung / Altersteilzeit
Die erste Zeile gibt es schon als worker_type, Lehrling seit 20260910140000.
Die zweite ist eine andere Frage: nicht *als was* jemand angestellt ist,
sondern *in welcher Form*. Beide gelten gleichzeitig — ein Praktikant ist
Arbeiter oder Angestellter, nicht statt dessen. In eine Liste gepresst
muesste man sich fuer eine der Antworten entscheiden und verloere die andere.
NOT NULL mit Vorgabe "Standard": jede Person ist in irgendeiner Form
beschaeftigt, und "nicht erfasst" waere keine Aussage, sondern eine Luecke,
die sich durch jede Auswertung zieht. Der Bestand bekommt damit einen
sichtbaren, korrigierbaren Ausgangswert statt eines leeren Feldes.
Die vier Funktionen wurden vollstaendig neu geschrieben. Die Selbstpruefung
haelt deshalb auch die Felder der vorigen Migration fest: eines beim
Uebertragen zu verlieren waere ein Fehler, der nirgends auffiele — die
Oberflaeche schickte den Wert weiter, und die Funktion ignorierte ihn.
201 lines
11 KiB
TypeScript
201 lines
11 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
import { statusLabel } from "@/lib/absence";
|
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
|
import { fmtName, todayIso } from "@/lib/format";
|
|
import { subtreeOf } from "@/lib/org";
|
|
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
|
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "@/lib/report-criteria";
|
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
|
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
|
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
|
import { withUser } from "@/lib/db";
|
|
import type { Database, Weekday } from "@/lib/types";
|
|
|
|
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
|
// über die Planstelle und die abgeleitete Berichtslinie.
|
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
|
|
org_unit_id: string | null;
|
|
position_number: string | null;
|
|
is_chief: boolean;
|
|
manager_id: string | null;
|
|
};
|
|
|
|
// Full raw data dump — every column on `employees`, not just the fields a
|
|
// pivot report groups by. Respects the same division/location/status/
|
|
// employment filters as the Berichte page. With `asOf` (Stichtag), status
|
|
// filtering happens against the *derived* status as of that date rather
|
|
// than the live `status` column — see deriveStatusAsOf.
|
|
export async function GET(request: NextRequest) {
|
|
const gate = await requireHrUser();
|
|
if ("denied" in gate) return gate.denied;
|
|
|
|
const params = request.nextUrl.searchParams;
|
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
|
const asOf = parseIsoDateParam(params.get("asOf"));
|
|
const filters: ReportFilters = {
|
|
division: params.get("division") ?? undefined,
|
|
location: params.get("location") ?? undefined,
|
|
status: params.get("status") ?? undefined,
|
|
criteria: parseCriteria((k) => params.get(k)),
|
|
};
|
|
|
|
const statuses = parseStatuses(filters.status);
|
|
|
|
const stichtag = asOf ?? todayIso();
|
|
|
|
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
|
|
gate.userId,
|
|
async (tx) => {
|
|
function employeeQuery() {
|
|
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
|
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
|
|
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
|
|
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
|
|
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
|
|
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
|
|
// „Ausgetreten" liess damit genau die Leute aus, die gerade
|
|
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
|
|
// und einmal im Speicher.
|
|
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
|
|
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
|
|
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
|
|
return applyCriteria(q, filters.criteria ?? LEERE_CRITERIA);
|
|
}
|
|
|
|
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
|
employeeQuery().execute(),
|
|
loadOrgLookups(tx),
|
|
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
|
loadDependentsCounts(tx),
|
|
loadPlacements(tx, { asOf: stichtag }),
|
|
loadReportingLineMap(tx, stichtag),
|
|
]);
|
|
|
|
return {
|
|
employees,
|
|
lookups: lookupResult.lookups,
|
|
orgMaps: lookupResult.orgMaps,
|
|
allEmployees,
|
|
dependentsCounts,
|
|
placements,
|
|
lines,
|
|
};
|
|
}
|
|
);
|
|
|
|
const managerName = new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)]));
|
|
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
|
// Export für "Produktion" nur die Bereichsleitung.
|
|
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
|
|
|
const criteria = filters.criteria ?? LEERE_CRITERIA;
|
|
const enriched: EmployeeRow[] = employees.flatMap((e) => {
|
|
const placement = placements.get(e.id);
|
|
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
|
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
|
|
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
|
|
return [{
|
|
...e,
|
|
org_unit_id: orgUnitId,
|
|
position_number: placement?.positionNumber ?? null,
|
|
is_chief: placement?.isChief ?? false,
|
|
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
|
|
}];
|
|
});
|
|
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
|
|
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
|
|
const filename = exportFilename("mitarbeiter-export", format);
|
|
|
|
const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns);
|
|
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
|
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
|
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
|
}
|
|
|
|
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
|
|
|
function employeeExportColumns(
|
|
lookups: OrgLookups,
|
|
managerName: Map<string, string>,
|
|
dependentsCounts: Map<string, number>,
|
|
asOf?: string
|
|
): ExportColumn<EmployeeRow>[] {
|
|
const columns: ExportColumn<EmployeeRow>[] = [
|
|
{ header: "Pers.-Nr.", get: (e) => e.personnel_number },
|
|
{ header: "Vorname", get: (e) => e.first_name },
|
|
{ header: "Nachname", get: (e) => e.last_name },
|
|
{ header: "Geschlecht", get: (e) => (e.gender === "m" ? "männlich" : "weiblich") },
|
|
{ header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" },
|
|
{ header: "SV-Nummer", get: (e) => e.sv_nummer },
|
|
{ header: "Staatsbürgerschaft", get: (e) => e.nationality },
|
|
{ header: "Aufenthaltstitel", get: (e) => e.hat_aufenthaltstitel },
|
|
{ header: "Aufenthaltstitel bis", get: (e) => e.aufenthaltstitel_bis, kind: "date" },
|
|
{ header: "Adresse", get: (e) => e.address },
|
|
{ header: "Postleitzahl", get: (e) => e.postal_code },
|
|
{ header: "Ort", get: (e) => e.city },
|
|
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
|
{ header: "Private E-Mail", get: (e) => e.email },
|
|
{ header: "Private Telefonnummer", get: (e) => e.phone },
|
|
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
|
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
|
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
|
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
|
{ header: "Position", get: (e) => e.job_title },
|
|
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
|
{ header: "Planstelle", get: (e) => e.position_number },
|
|
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
|
|
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
|
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
|
// work_days is stored in click order (see RoleEmploymentFields), not
|
|
// guaranteed chronological — re-sort Mo→So for the export.
|
|
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
|
|
{ header: "Vertragsart", get: (e) => e.contract_type },
|
|
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
|
|
{ header: "Beschäftigtengruppe", get: (e) => e.worker_type },
|
|
{ header: "Mitarbeiterart", get: (e) => e.mitarbeiterart },
|
|
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
|
|
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
|
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
|
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
|
|
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
|
|
{ header: "Personenkreis", get: (e) => e.kuendigungsschutz_grund ?? "" },
|
|
{ header: "Kündigungsschutz ab", get: (e) => e.kuendigungsschutz_ab, kind: "date" },
|
|
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
|
|
// Der Bericht, den der Workshop verlangt hat (Anforderung 5a): wer ist
|
|
// begünstigt behindert, mit wieviel Prozent, ab und bis wann. Vier
|
|
// Spalten statt einer zusammengesetzten — in Excel wird danach gefiltert
|
|
// und summiert, und ein Text wie „50% seit 2019" liesse beides nicht zu.
|
|
{ header: "Begünstigt behindert", get: (e) => e.ist_beguenstigt_behindert },
|
|
{ header: "Grad der Behinderung (%)", get: (e) => e.behinderung_grad ?? "" },
|
|
{ header: "Bescheid ab", get: (e) => e.behinderung_ab, kind: "date" },
|
|
{ header: "Bescheid bis", get: (e) => e.behinderung_bis, kind: "date" },
|
|
{ header: "Teilzeitvariante", get: (e) => e.teilzeit_art ?? "" },
|
|
{ header: "Teilzeit bis", get: (e) => e.teilzeit_bis, kind: "date" },
|
|
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
|
|
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
|
|
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
|
|
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
|
|
{ header: "C-Level", get: (e) => e.is_c_level },
|
|
{ header: "Paygrade", get: (e) => e.paygrade },
|
|
{ header: "Herkunft", get: (e) => e.source },
|
|
// The export shows the display name, not the raw enum value — a payroll
|
|
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
|
|
// would just cause questions.
|
|
{ header: "Status", get: (e) => statusLabel(e.status) },
|
|
{ header: "Art der Langzeitabwesenheit", get: (e) => e.absence_type },
|
|
{ header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" },
|
|
{ header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" },
|
|
{ header: "Austrittsgrund", get: (e) => e.exit_reason },
|
|
{ header: "Abwesenheit ab", get: (e) => e.karenz_start_date, kind: "date" },
|
|
{ header: "Rückkehr geplant", get: (e) => e.karenz_return_date, kind: "date" },
|
|
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
|
|
];
|
|
if (asOf) {
|
|
const statusIndex = columns.findIndex((c) => c.header === "Status");
|
|
columns.splice(statusIndex + 1, 0, { header: `Status zum Stichtag (${asOf})`, get: (e) => deriveStatusAsOf(e, asOf) });
|
|
}
|
|
return columns;
|
|
}
|