The export offered four criteria — unit, location, status, employment type — while the employee record carries around twenty selectable attributes. Anything else had to be filtered by hand in Excel afterwards, which is how a payroll hand-off stops matching the application it came from. All of them are now filters: contract type, blue/white collar, collective agreement, paygrade, internal/external, gender, company car and its drivetrain, works council, lateral leadership, C-level, type of long-term absence, weekday worked, dependents on file, and open ranges for entry, exit, birth date and weekly hours. The unit filter covers every level rather than only divisions, so a single department can be selected without going the long way round. They live in one table in lib/report-criteria.ts, which the filter panel builds itself from, the parser validates against, and the query turns into conditions. A new criterion is one entry there and nothing else — and it cannot end up working in the report while being silently ignored by the export. The two export links and the saved-report config now carry the query string through as it stands instead of listing the parameters they know about. That enumeration was the actual defect: adding a filter meant remembering three separate places, and forgetting one produced an export that quietly disagreed with the figure on screen. Validation is not housekeeping here. These values reach SQL comparisons and the download filename, i.e. a Content-Disposition header; what is not in the list does not get through. The company car dropdown leaves the employee list. It is one of twenty equals under Berichte now, where the selection can also be exported — which was the point of asking in the first place. 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 { 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, `${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;
|
|
}
|