Employee names now read "Winkler, Hannah" wherever a person appears in a list, a table, a heading or a tree node. That is the order a personnel list is kept in, it is the order people are looked up in, and it finally matches the sorting — the employee list has always been ordered by surname, which made an alphabetical page look unsorted. The name was being assembled inline in about twenty places. A rename that catches half of them is worse than none, so it now goes through fmtName in lib/format.ts and every display site calls it. Sentences keep the natural order: "Hannah Winkler wurde versetzt" reads like German, "Winkler, Hannah wurde versetzt" reads like a form. So the toasts are unchanged and only labels moved. Two things the change would have quietly broken: The org chart's own filter matched against "first last". It now matches either order, with or without the comma, so typing what you see works and so does typing what you remember. The print model sorted by the last word of the composed name, which happened to be the surname and is now the first name — every printed unit would have come out sorted by first name. It sorts on the surname field itself now, which is what it meant all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
9.0 KiB
TypeScript
175 lines
9.0 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: "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: "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;
|
|
}
|