The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
6.0 KiB
TypeScript
156 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { terminateEmployee } from "@/actions/employees";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
|
import { SlideOver } from "@/components/ui/SlideOver";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import type { Database } from "@/lib/types";
|
|
import { fmtDate, fmtName } from "@/lib/format";
|
|
|
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
|
|
|
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
|
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
|
const NO_SHOW = "No Show";
|
|
const EXIT_REASONS = [
|
|
"Einvernehmliche Auflösung",
|
|
"Kündigung AN",
|
|
"Kündigung AG",
|
|
"Befristungsablauf",
|
|
"Pensionierung",
|
|
"Entlassung",
|
|
NO_SHOW,
|
|
];
|
|
type TerminatePanelProps = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
employee: EmployeeRow;
|
|
directReportCount: number;
|
|
};
|
|
|
|
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
const [exitDate, setExitDate] = useState("");
|
|
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
|
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
|
// übrigen zu verstellen.
|
|
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
|
const [note, setNote] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
|
|
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
|
// an dem die Person hätte anfangen sollen. Die Datenbank setzt es ohnehin
|
|
// so; hier steht es sichtbar, damit niemand ein Datum eintippt, das dann
|
|
// stillschweigend übergangen wird.
|
|
const istNoShow = reason === NO_SHOW;
|
|
const wirksamesDatum = istNoShow ? employee.entry_date : exitDate;
|
|
|
|
async function handleSubmit() {
|
|
if (!wirksamesDatum) {
|
|
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
|
return;
|
|
}
|
|
setPending(true);
|
|
const result = await terminateEmployee({
|
|
employee_id: employee.id,
|
|
exit_date: wirksamesDatum,
|
|
exit_reason: reason,
|
|
note,
|
|
});
|
|
setPending(false);
|
|
if (result.success) {
|
|
showToast(
|
|
istNoShow
|
|
? `${employee.first_name} ${employee.last_name} ist nicht angetreten.`
|
|
: `Austritt für ${employee.first_name} ${employee.last_name} erfasst.`
|
|
);
|
|
router.refresh();
|
|
onClose();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SlideOver
|
|
open={open}
|
|
onClose={onClose}
|
|
title={istNoShow ? "Nicht angetreten" : "Austritt"}
|
|
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button variant="danger" onClick={handleSubmit} pending={pending}>
|
|
Austritt bestätigen
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
{/* Zuerst, und nicht zu übersehen: bei besonderem Kündigungsschutz
|
|
gelten eigene Regeln, bevor beendet werden darf. Die Anwendung
|
|
entscheidet das nicht — sie darf es aber auch nicht verschweigen,
|
|
und im Vertragsblatt nachzusehen ist genau der Schritt, den man
|
|
unter Zeitdruck auslässt. */}
|
|
{employee.has_kuendigungsschutz && (
|
|
<div className="rounded border border-danger-solid bg-danger-bg px-3 py-2 text-sm font-semibold text-danger-text">
|
|
Achtung: besonderer Kündigungsschutz
|
|
{employee.kuendigungsschutz_bis ? ` bis ${fmtDate(employee.kuendigungsschutz_bis)}` : " (Ende nicht erfasst)"}.
|
|
<span className="block font-normal">
|
|
Vor einer Beendigung ist zu prüfen, ob sie zulässig ist — je nach Grund braucht es eine Zustimmung des
|
|
Betriebsrats oder des Gerichts.
|
|
</span>
|
|
</div>
|
|
)}
|
|
{directReportCount > 0 && (
|
|
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
|
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
|
</div>
|
|
)}
|
|
<SelectField
|
|
label="Beendigungsart"
|
|
value={reason}
|
|
onChange={setReason}
|
|
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
|
/>
|
|
<TextField
|
|
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
|
required
|
|
type="date"
|
|
value={wirksamesDatum}
|
|
disabled={istNoShow}
|
|
onChange={setExitDate}
|
|
hint={
|
|
istNoShow
|
|
? "Wer nie angetreten ist, scheidet am Tag seines Eintritts aus. Damit gibt es keinen Tag, an dem die Person als beschäftigt zählt."
|
|
: undefined
|
|
}
|
|
/>
|
|
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
|
|
{istNoShow && (
|
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
|
Die Planstelle wird wieder frei und gilt als nie besetzt. In allen Auswertungen zählt die Person an keinem
|
|
Stichtag als beschäftigt.
|
|
</p>
|
|
)}
|
|
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme —
|
|
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
|
echten Austritt legt terminateEmployee sie in derselben
|
|
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
|
hier sucht und nichts findet. */}
|
|
{!istNoShow && (
|
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
|
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SlideOver>
|
|
);
|
|
}
|