diff --git a/app/api/export/lolyo/route.ts b/app/api/export/lolyo/route.ts new file mode 100644 index 0000000..b5ba753 --- /dev/null +++ b/app/api/export/lolyo/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export"; +import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl"; +import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo"; +import { requireHrUser } from "@/lib/auth/require-hr"; +import { withUser } from "@/lib/db"; + +// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in +// lib/lolyo.ts. +// +// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern +// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen +// will, stellt das auf der Berichtsseite ein, bevor er exportiert. + +/** + * Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden. + * + * Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig + * und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer + * lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil + * heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung + * steht in lib/passwort.ts. + * + * Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch + * dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange, + * bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den + * man weiterleitet. + */ +const STARTPASSWORT = "Initialpasswort2026!"; + +export async function GET(request: NextRequest) { + const gate = await requireHrUser(); + if ("denied" in gate) return gate.denied; + + const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams); + const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters })); + + const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT)); + const spalten = lolyoSpalten(); + const filename = exportFilename("lolyo-export", format); + + const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV); + // Derselbe Umweg über Blob wie im vollständigen Export (TS#59417). + return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); +} diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 98e2a42..6816692 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -49,6 +49,21 @@ const EVENT_TYPES = EREIGNIS_AUSWAHL; /** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */ const VORSCHAU_NAMEN = 12; +/** + * Die Exporte, die in ein anderes System geladen werden. + * + * Sie stehen zusammen und getrennt vom Datenexport darüber, weil sie etwas + * anderes sind: der Datenexport gibt Zahlen heraus, die jemand ansieht, diese + * hier legen im Zielsystem Datensätze an. Nebeneinander in einer Reihe + * gleichartiger Karten war das nicht zu sehen — und eine versehentlich + * eingeschränkte Auswahl wirkt sich hier ganz anders aus. + */ +const ZIELSYSTEME: readonly { name: string; pfad: string }[] = [ + { name: "Honestly — Mitarbeiterbefragung", pfad: "/api/export/honestly" }, + { name: "Cornerstone — Lernsystem", pfad: "/api/export/cornerstone" }, + { name: "LOLYO — Mitarbeiter-App", pfad: "/api/export/lolyo" }, +]; + type SavedReport = { id: string; name: string; config: Record }; type OrgOption = { id: string; name: string }; @@ -674,44 +689,31 @@ export function ReportsPageClient(props: ReportsPageClientProps) { - {/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an - Ereignisse. Dieselben Filter wie der vollständige Export darüber - — was dort steht, steht auch hier. */} + {/* Nur im Bestand: ein Zielsystem führt Personen, keine Ereignisse. + Dieselben Filter wie der vollständige Export darüber. */} {mode === "snapshot" && (
-

Honestly-Report

- -
- - - CSV - - - - Excel - -
-
- )} - - {mode === "snapshot" && ( -
-

Cornerstone Report

+

Exporte für Zielsysteme

- Load-File für Cornerstone: 57 Spalten in der Reihenfolge der Vorlage, kommagetrennt, Datum - TT.MM.JJJJ, Systemwerte statt Anzeigenamen (Active, Working, Employee). Ohne Location ID und - Position ID — dafür fehlen die Kennungen des Zielsystems. + Load-Dateien für Fremdsysteme, jede in deren eigenem Format. Für alle gilt dieselbe Auswahl.

-
- - - CSV - - - - Excel - +
+ {ZIELSYSTEME.map((z) => ( +
+

{z.name}

+ +
+ ))}
)} diff --git a/lib/lolyo.ts b/lib/lolyo.ts new file mode 100644 index 0000000..16ac5fe --- /dev/null +++ b/lib/lolyo.ts @@ -0,0 +1,119 @@ +import type { CsvForm, ExportColumn } from "./export"; + +// Der Export für LOLYO, die Mitarbeiter-App: eine Zeile je Person, die dort +// einen Zugang bekommen soll. +// +// Diese Datei tut etwas anderes als die beiden Exporte daneben. Honestly lädt +// zu einer Befragung ein, Cornerstone führt Stammdaten nach — hier entstehen +// **Konten**, samt Passwort in der Zeile. Wer die Datei hat, hat damit einen +// Zugang zu jedem Konto darin, solange niemand sein Passwort gewechselt hat. +// Deshalb steht das Passwort nicht in dieser Datei: die Route reicht es herein +// (app/api/export/lolyo/route.ts), und es gelangt damit nie in ein Bündel, das +// der Browser lädt. Dieselbe Überlegung steht in lib/passwort.ts. +// +// Kopfzeile und Trennzeichen kommen aus der Vorlage des Anbieters, Zeichen für +// Zeichen. Die gemischte Schreibweise — „Titel prefix" deutsch, „Title Suffix" +// englisch — steht so darin; sie hier stillschweigend zu vereinheitlichen +// hiesse, eine Spalte zu liefern, die LOLYO nicht zuordnet. + +/** + * Semikolon, kein BOM — so sieht die Vorlage aus. + * + * Die Vorgabe von toCsv ist für Excel gemacht und setzt ein BOM voran; das + * hinge unsichtbar am Namen der ersten Spalte, und „Benutzer/Code" käme beim + * Einlesen in keiner Zuordnung mehr vor. + */ +export const LOLYO_CSV: CsvForm = { trenner: ";", bom: false }; + +/** Für alle dieselbe. Wer eine andere braucht, stellt sie in LOLYO um, nicht hier. */ +export const LOLYO_SPRACHE = "DE"; + +/** + * Die Gruppe, in die alle kommen. + * + * Fest vorgegeben, nicht aus der Organisationseinheit abgeleitet: LOLYO-Gruppen + * sind Verteiler in der App und nicht die Aufbauorganisation. Beides + * gleichzusetzen erzeugte 78 Gruppen, die dort niemand bestellt hat. + */ +export const LOLYO_GRUPPE = "Demo-Gruppe"; + +/** LOLYO holt die Bestätigung selbst ein; aus Alpenwerk kommt sie nie bestätigt. */ +export const LOLYO_EMAIL_BESTAETIGT = "0"; + +/** + * Die Anrede aus dem Geschlecht. + * + * Alpenwerk kennt genau zwei Werte (gender_type), und die Vorgabe nennt genau + * zwei Anreden. Ein dritter Wert bliebe hier leer statt geraten — eine falsche + * Anrede steht in jeder Nachricht, die die App verschickt. + */ +export function anrede(gender: string): string { + if (gender === "m") return "Herr"; + if (gender === "w") return "Frau"; + return ""; +} + +/** Die Person, so wie der Export sie braucht. */ +export type LolyoQuelle = { + id: string; + company_email: string | null; + title_prefix: string[]; + title_suffix: string[]; + first_name: string; + last_name: string; + job_title: string; + phone: string | null; + gender: string; +}; + +export type LolyoZeile = Record; + +/** + * Eine Zeile. + * + * Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das + * hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene + * Kennung, die wir treffen müssten — die UUID ist der Schlüssel, über den sich + * ein späterer Abgleich wieder auf dieselbe Person bezieht. + * + * Die E-Mail-Spalte trägt die **Firmenadresse** und niemals die private aus + * employees.email: die Datei legt Konten bei einem fremden Anbieter an. + */ +export function baueLolyoZeile(p: LolyoQuelle, passwort: string): LolyoZeile { + return { + "Benutzer/Code": p.id, + Email: p.company_email ?? "", + Passwort: passwort, + "Titel prefix": p.title_prefix.join(" "), + Vorname: p.first_name, + Nachname: p.last_name, + "Title Suffix": p.title_suffix.join(" "), + Position: p.job_title, + Gruppen: LOLYO_GRUPPE, + Telefon: p.phone ?? "", + Anrede: anrede(p.gender), + Sprache: LOLYO_SPRACHE, + "E-Mail bestätigt": LOLYO_EMAIL_BESTAETIGT, + }; +} + +/** Die Spalten in der Reihenfolge der Vorlage. */ +export const LOLYO_SPALTEN: readonly string[] = [ + "Benutzer/Code", + "Email", + "Passwort", + "Titel prefix", + "Vorname", + "Nachname", + "Title Suffix", + "Position", + "Gruppen", + "Telefon", + "Anrede", + "Sprache", + "E-Mail bestätigt", +]; + +export function lolyoSpalten(): ExportColumn[] { + return LOLYO_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" })); +} diff --git a/tests/unit/lolyo.test.ts b/tests/unit/lolyo.test.ts new file mode 100644 index 0000000..fa159a9 --- /dev/null +++ b/tests/unit/lolyo.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { toCsv } from "@/lib/export"; +import { + anrede, + baueLolyoZeile, + LOLYO_CSV, + LOLYO_EMAIL_BESTAETIGT, + LOLYO_GRUPPE, + LOLYO_SPALTEN, + LOLYO_SPRACHE, + lolyoSpalten, + type LolyoQuelle, +} from "@/lib/lolyo"; + +// Die Datei legt in LOLYO Konten an. Eine falsche Spalte fällt hier nicht auf, +// sondern dort — als Konto, das niemandem gehört, oder als Zeile, die der +// Import abweist. + +// Die Kopfzeile aus der Vorlage des Anbieters, Zeichen für Zeichen. +const VORGABE = "Benutzer/Code;Email;Passwort;Titel prefix;Vorname;Nachname;Title Suffix;Position;Gruppen;Telefon;Anrede;Sprache;E-Mail bestätigt"; + +const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234"; +const PASSWORT = "Startpasswort1!"; + +function person(teil: Partial = {}): LolyoQuelle { + return { + id: UUID, + company_email: "max.alpentest@alpenwerk-firmentest.com", + title_prefix: ["Mag."], + title_suffix: ["MBA"], + first_name: "Max", + last_name: "Alpentest", + job_title: "HR-Manager", + phone: "+4366412345678", + gender: "m", + ...teil, + }; +} + +const zeile = (teil: Partial = {}) => baueLolyoZeile(person(teil), PASSWORT); + +describe("Spalten", () => { + it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => { + expect(LOLYO_SPALTEN.join(";")).toBe(VORGABE); + }); + + it("sind 13", () => { + expect(LOLYO_SPALTEN).toHaveLength(13); + }); + + it("werden alle gefüllt — keine Spalte ohne Zuordnung", () => { + const z = zeile(); + for (const spalte of LOLYO_SPALTEN) { + expect(z, `Spalte "${spalte}" fehlt in der Zeile`).toHaveProperty(spalte); + } + expect(Object.keys(z).sort()).toEqual([...LOLYO_SPALTEN].sort()); + }); +}); + +describe("Die Zuordnung", () => { + it("nimmt als Benutzercode die Alpenwerk-UUID", () => { + expect(zeile()["Benutzer/Code"]).toBe(UUID); + }); + + it("nimmt die Firmenadresse und nie die private", () => { + // Die Datei legt Konten bei einem fremden Anbieter an. Eine Privatadresse + // hat dort nichts verloren; ohne Firmenadresse bleibt die Zelle leer. + expect(zeile().Email).toBe("max.alpentest@alpenwerk-firmentest.com"); + expect(zeile({ company_email: null }).Email).toBe(""); + }); + + it("trägt das Passwort ein, das die Route hereinreicht", () => { + expect(zeile().Passwort).toBe(PASSWORT); + }); + + it("setzt Titel, Name und Position aus Alpenwerk", () => { + const z = zeile(); + expect(z["Titel prefix"]).toBe("Mag."); + expect(z.Vorname).toBe("Max"); + expect(z.Nachname).toBe("Alpentest"); + expect(z["Title Suffix"]).toBe("MBA"); + expect(z.Position).toBe("HR-Manager"); + }); + + it("fügt mehrere Titel zusammen und lässt keine zu einer leeren Zelle werden", () => { + expect(zeile({ title_prefix: ["Dr.", "Mag."] })["Titel prefix"]).toBe("Dr. Mag."); + expect(zeile({ title_prefix: [], title_suffix: [] })["Titel prefix"]).toBe(""); + }); + + it("setzt Gruppe, Sprache und Bestätigung fest", () => { + const z = zeile(); + expect(z.Gruppen).toBe(LOLYO_GRUPPE); + expect(z.Sprache).toBe(LOLYO_SPRACHE); + expect(z["E-Mail bestätigt"]).toBe(LOLYO_EMAIL_BESTAETIGT); + }); + + it("leitet die Anrede aus dem Geschlecht ab", () => { + expect(anrede("m")).toBe("Herr"); + expect(anrede("w")).toBe("Frau"); + // Lieber leer als geraten: eine falsche Anrede steht in jeder Nachricht, + // die die App verschickt. + expect(anrede("")).toBe(""); + }); + + it("lässt eine fehlende Telefonnummer leer", () => { + expect(zeile({ phone: null }).Telefon).toBe(""); + }); +}); + +describe("Die Datei", () => { + it("ist semikolongetrennt und ohne BOM", () => { + // Das BOM hinge unsichtbar am Namen der ersten Spalte, und + // „Benutzer/Code" käme beim Einlesen in keiner Zuordnung mehr vor. + const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV); + expect(csv.startsWith("Benutzer/Code;")).toBe(true); + expect(csv.charCodeAt(0)).not.toBe(0xfeff); + }); + + it("beginnt mit der Kopfzeile der Vorlage", () => { + const csv = toCsv([], lolyoSpalten(), LOLYO_CSV); + expect(csv.split("\r\n")[0]).toBe(VORGABE); + }); + + it("maskiert eine Telefonnummer, die Excel als Formel läse", () => { + // „+43…" beginnt mit einem Pluszeichen — ohne Schutz macht Excel daraus + // beim Öffnen eine Formel und zeigt einen Fehler statt der Nummer. + const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV); + expect(csv).toContain("'+4366412345678"); + }); +});