Employees from outside the EU, the EEA and Switzerland need a residence permit, and HR needs to know when it expires — about eighty people in the current data, across Türkei, Serbien and Bosnien. Two columns, the same shape as the dismissal protection: a flag and a date that only means anything with it. The date is optional, because an open-ended permit has none and a mandatory field would force an invented one. The nationality coupling deliberately stays out of the database. Putting it there would mean keeping the country list in two places — SQL and lib/countries.ts, where the picker needs it anyway — so an EU accession would become a migration instead of a line in a list. Worse, correcting somebody's nationality would fail the constraint while the old permit was still attached, which is exactly the moment someone is fixing a mistake. The UI decides whether the fields appear, and clears them when the nationality moves into the free-movement area. So the list is the load-bearing part, and it is tested: 31 entries, all of them values the picker can actually produce, no duplicates, no third countries. A missing nationality reads as "no permit required" — an unanswered question is a reason to record it, not to demand papers. The permit shows on the Stammdaten tab only for the nationalities it applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian citizenship would look like information rather than a question that does not arise. Filter by it and by when it expires — the question behind that being "whose permit runs out next quarter" — plus columns in the export. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
9.5 KiB
TypeScript
181 lines
9.5 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 { 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/supabase/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);
|
|
if (!asOf) q = q.where("status", "in", statuses);
|
|
// 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: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
|
|
{ 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: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_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;
|
|
}
|