Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four items, never sent anywhere. Nothing in terminateEmployee's payload carried them; ticking a box there recorded exactly nothing. It's replaced with the same kind of list the entries got: its own tab, appearing the moment an exit is recorded, with one item per row, a comment on each, and — unlike the fieldset — a record of who touched it and when. The eleven items come from the same printed sheet as the entry list. Nine are plain checkboxes. Two are text fields under "Vermerke": remaining vacation and the balance transferred for payout — the sheet names "Überleitung Salden für Auszahlung" twice, once as a task to do and once as the actual figure, and those are genuinely two different questions, kept as two items. Where the sheet still says "GKK" rather than today's "ÖGK", it's left as written — that's the name the process runs under internally, not a typo. No Show gets no list. Never having worked a single day, there's no IT access to revoke, no GKK registration to undo, no Dienstzettel to collect — an empty checklist there would be a label with nothing behind it. Both the tab and the auto-creation on exit check for this specifically, not just the "Ausgetreten" status that No Show shares with a real exit. Rehiring the same person hides the tab again — the data stays, since it happened, but a checklist for someone currently working has nothing to point at. The engine (what counts as done, how progress is computed) moved into lib/checklist.ts so onboarding and offboarding can't drift into two different ideas of "done" the way two independent copies eventually do; lib/onboarding.ts and lib/offboarding.ts bind it to their own item list, and the tab UI is a single ChecklistPanel bound the same way. Checked against the real database: a real exit creates all eleven items in the same transaction as the exit itself; a No Show creates none; rehiring flips the tab off while the old answers stay queryable. One false alarm during that check turned out to be the user's own clicks on a real employee's onboarding list, made in the browser while trying the earlier feature — left untouched, not test debris. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { currentUserId } from "@/lib/auth/session";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { withUser } from "@/lib/db";
|
import { withUser } from "@/lib/db";
|
||||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||||
|
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
|
||||||
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
||||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||||
|
|
||||||
@@ -91,7 +92,50 @@ export async function terminateEmployee(payload: {
|
|||||||
exit_reason: string;
|
exit_reason: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
|
// No Show ist kein Austritt im gewohnten Sinn — die Person hat nie
|
||||||
|
// angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der
|
||||||
|
// eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste
|
||||||
|
// entstünde leer und wäre ein Etikett ohne Inhalt.
|
||||||
|
if (payload.exit_reason === "No Show") {
|
||||||
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withUser(await currentUserId(), async (tx) => {
|
||||||
|
await callFunction(tx, "terminate_employee", payload as unknown as Record<string, unknown>);
|
||||||
|
// In derselben Transaktion: ein Austritt ohne Checkliste wäre ein
|
||||||
|
// halb erfasster Austritt, und sie später anzulegen hiesse, dass
|
||||||
|
// jemand daran denken muss.
|
||||||
|
await callFunction(tx, "start_offboarding", {
|
||||||
|
employee_id: payload.employee_id,
|
||||||
|
item_keys: OFFBOARDING_PUNKTE.map((x) => x.key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
|
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */
|
||||||
|
export async function setOffboardingTask(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
item_key: string;
|
||||||
|
erledigt?: boolean;
|
||||||
|
wert?: string | null;
|
||||||
|
kommentar?: string | null;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */
|
||||||
|
export async function startOffboarding(employeeId: string): Promise<ActionResult> {
|
||||||
|
return callRpc(
|
||||||
|
"start_offboarding",
|
||||||
|
{ employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) },
|
||||||
|
[`/employees/${employeeId}`]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function transferEmployee(payload: {
|
export async function transferEmployee(payload: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
|||||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||||
|
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, openPositions, byId } = data;
|
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, offboarding, openPositions, byId } = data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployeeDetail
|
<EmployeeDetail
|
||||||
@@ -33,6 +33,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
|||||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||||
kostenstelle={kostenstelle}
|
kostenstelle={kostenstelle}
|
||||||
onboarding={onboarding}
|
onboarding={onboarding}
|
||||||
|
offboarding={offboarding}
|
||||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
||||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
||||||
// zweimal da.
|
// zweimal da.
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useState } from "react";
|
|||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
|
import type { AufgabenStand } from "@/lib/checklist";
|
||||||
import { fmtFullName, tenure } from "@/lib/format";
|
import { fmtFullName, tenure } from "@/lib/format";
|
||||||
import { fortschritt, type AufgabenStand } from "@/lib/onboarding";
|
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
|
||||||
|
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import type { Database } from "@/lib/supabase/types";
|
import type { Database } from "@/lib/supabase/types";
|
||||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||||
@@ -18,6 +20,7 @@ import { TerminatePanel } from "./panels/TerminatePanel";
|
|||||||
import { TransferPanel } from "./panels/TransferPanel";
|
import { TransferPanel } from "./panels/TransferPanel";
|
||||||
import { HistorieTab } from "./tabs/HistorieTab";
|
import { HistorieTab } from "./tabs/HistorieTab";
|
||||||
import { NotizenTab } from "./tabs/NotizenTab";
|
import { NotizenTab } from "./tabs/NotizenTab";
|
||||||
|
import { OffboardingTab } from "./tabs/OffboardingTab";
|
||||||
import { OnboardingTab } from "./tabs/OnboardingTab";
|
import { OnboardingTab } from "./tabs/OnboardingTab";
|
||||||
import { OrganisationTab } from "./tabs/OrganisationTab";
|
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||||
@@ -40,6 +43,8 @@ type EmployeeDetailProps = {
|
|||||||
kostenstelle: { code: string; name: string } | null;
|
kostenstelle: { code: string; name: string } | null;
|
||||||
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
|
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
|
||||||
onboarding: AufgabenStand[];
|
onboarding: AufgabenStand[];
|
||||||
|
/** Der Stand der Offboarding-Checkliste; leer heisst: es gibt keine. */
|
||||||
|
offboarding: AufgabenStand[];
|
||||||
manager: MiniEmployee | null;
|
manager: MiniEmployee | null;
|
||||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||||
formalManager: MiniEmployee | null;
|
formalManager: MiniEmployee | null;
|
||||||
@@ -52,17 +57,22 @@ type EmployeeDetailProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Historie", "HR-Notizen"] as const;
|
const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offboarding", "Historie", "HR-Notizen"] as const;
|
||||||
|
type Tab = (typeof ALLE_TABS)[number];
|
||||||
|
|
||||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||||
const { employee, placement, breadcrumb, kostenstelle, onboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
const [tab, setTab] = useState<Tab>("Stammdaten");
|
||||||
const [panel, setPanel] = useState<PanelType>(null);
|
const [panel, setPanel] = useState<PanelType>(null);
|
||||||
|
|
||||||
const location = locations.find((l) => l.id === employee.location_id);
|
const location = locations.find((l) => l.id === employee.location_id);
|
||||||
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
|
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
|
||||||
// erfahren, dass nichts zu tun ist.
|
// erfahren, dass nichts zu tun ist.
|
||||||
const offeneAufgaben = onboarding.length > 0 ? fortschritt(onboarding).gesamt - fortschritt(onboarding).erledigt : 0;
|
const offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0;
|
||||||
|
const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0;
|
||||||
|
|
||||||
|
const zeigtOffboarding = gehoertOffboarding(employee);
|
||||||
|
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
|
||||||
|
|
||||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||||
const canEditData = employee.status !== "Ausgetreten";
|
const canEditData = employee.status !== "Ausgetreten";
|
||||||
@@ -143,7 +153,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
{/* Tabs, so the list gets the tablist role and each button says
|
{/* Tabs, so the list gets the tablist role and each button says
|
||||||
whether it is the selected one. */}
|
whether it is the selected one. */}
|
||||||
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
|
||||||
{TABS.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -153,7 +163,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
|
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t === "Onboarding" && offeneAufgaben > 0 ? `Onboarding ${offeneAufgaben}` : t}
|
{t === "HR-Notizen"
|
||||||
|
? `HR-Notizen ${notes.length}`
|
||||||
|
: t === "Onboarding" && offeneOnboarding > 0
|
||||||
|
? `Onboarding ${offeneOnboarding}`
|
||||||
|
: t === "Offboarding" && offeneOffboarding > 0
|
||||||
|
? `Offboarding ${offeneOffboarding}`
|
||||||
|
: t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -174,6 +190,9 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
{tab === "Onboarding" && (
|
{tab === "Onboarding" && (
|
||||||
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
|
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "Offboarding" && zeigtOffboarding && (
|
||||||
|
<OffboardingTab employeeId={employee.id} staende={offboarding} vorhanden={offboarding.length > 0} />
|
||||||
|
)}
|
||||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ const EXIT_REASONS = [
|
|||||||
"Entlassung",
|
"Entlassung",
|
||||||
NO_SHOW,
|
NO_SHOW,
|
||||||
];
|
];
|
||||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
|
||||||
|
|
||||||
type TerminatePanelProps = {
|
type TerminatePanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -42,7 +40,6 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
// übrigen zu verstellen.
|
// übrigen zu verstellen.
|
||||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
// Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag,
|
||||||
@@ -142,22 +139,16 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
Stichtag als beschäftigt.
|
Stichtag als beschäftigt.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */}
|
{/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme —
|
||||||
<fieldset hidden={istNoShow}>
|
deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen
|
||||||
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
|
echten Austritt legt terminateEmployee sie in derselben
|
||||||
<div className="flex flex-col gap-2">
|
Transaktion an; hier steht nur die Ankündigung, damit niemand sie
|
||||||
{CHECKLIST_ITEMS.map((item, i) => (
|
hier sucht und nichts findet. */}
|
||||||
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
|
{!istNoShow && (
|
||||||
<input
|
<p className="rounded bg-surface px-3 py-2 text-sm text-ink-body">
|
||||||
type="checkbox"
|
Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung.
|
||||||
checked={checked[i]}
|
</p>
|
||||||
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
|
)}
|
||||||
/>
|
|
||||||
{item}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
249
components/employees/tabs/ChecklistPanel.tsx
Normal file
249
components/employees/tabs/ChecklistPanel.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import {
|
||||||
|
alleReichungen,
|
||||||
|
fortschrittVon,
|
||||||
|
istErledigt,
|
||||||
|
type AufgabenGruppe,
|
||||||
|
type AufgabenPunkt,
|
||||||
|
type AufgabenStand,
|
||||||
|
} from "@/lib/checklist";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
|
// Die Checkliste, die bisher ein Blatt Papier war — geteilt zwischen
|
||||||
|
// Onboarding und Offboarding. Beide sind dieselbe Sache mit anderen Punkten:
|
||||||
|
// eine Liste, die sich abhaken, mit Ja/Nein beantworten oder mit einem Wert
|
||||||
|
// füllen lässt, mit Kommentar, und mit einem Vermerk, **wer** wann etwas
|
||||||
|
// eingetragen hat — auf dem Blatt stand nur ein Haken.
|
||||||
|
//
|
||||||
|
// Gespeichert wird beim Klick, nicht beim Absenden. Eine Checkliste wird über
|
||||||
|
// Tage abgearbeitet, oft zwischen zwei anderen Dingen; ein „Speichern"-Knopf
|
||||||
|
// am Ende wäre die Stelle, an der ein halber Vormittag verlorengeht.
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
gruppen: readonly AufgabenGruppe[];
|
||||||
|
staende: AufgabenStand[];
|
||||||
|
/** Ob überhaupt eine Liste existiert. */
|
||||||
|
vorhanden: boolean;
|
||||||
|
/** Text, solange keine Liste existiert. */
|
||||||
|
leerText: string;
|
||||||
|
anlegenLabel: string;
|
||||||
|
onSpeichern: (
|
||||||
|
itemKey: string,
|
||||||
|
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
|
||||||
|
) => Promise<ActionResult>;
|
||||||
|
onAnlegen: () => Promise<ActionResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChecklistPanel({ gruppen, staende, vorhanden, leerText, anlegenLabel, onSpeichern, onAnlegen }: Props) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [laeuft, setLaeuft] = useState<string | null>(null);
|
||||||
|
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const punkte = alleReichungen(gruppen);
|
||||||
|
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
||||||
|
const stand = fortschrittVon(punkte, staende);
|
||||||
|
|
||||||
|
async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) {
|
||||||
|
setLaeuft(key);
|
||||||
|
const ergebnis = await onSpeichern(key, teil);
|
||||||
|
setLaeuft(null);
|
||||||
|
if (ergebnis.success) router.refresh();
|
||||||
|
else showToast(ergebnis.error ?? "Konnte nicht gespeichert werden.", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listeAnlegen() {
|
||||||
|
setLaeuft("neu");
|
||||||
|
const ergebnis = await onAnlegen();
|
||||||
|
setLaeuft(null);
|
||||||
|
if (ergebnis.success) {
|
||||||
|
showToast("Checkliste angelegt.");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vorhanden) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<p className="text-sm text-ink-body">{leerText}</p>
|
||||||
|
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
|
||||||
|
{anlegenLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anteil = Math.round((stand.erledigt / stand.gesamt) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded border border-border bg-surface px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-ink">
|
||||||
|
{stand.erledigt} von {stand.gesamt} erledigt
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
{stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`}
|
||||||
|
{stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${anteil === 100 ? "bg-success-text" : "bg-brand-500"}`}
|
||||||
|
style={{ width: `${Math.max(2, anteil)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gruppen.map((gruppe) => (
|
||||||
|
<div key={gruppe.key}>
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">{gruppe.label}</h3>
|
||||||
|
<ul className="mt-2 flex flex-col divide-y divide-border-subtle">
|
||||||
|
{gruppe.punkte.map((punkt) => (
|
||||||
|
<Zeile
|
||||||
|
key={punkt.key}
|
||||||
|
punkt={punkt}
|
||||||
|
stand={karte.get(punkt.key)}
|
||||||
|
laeuft={laeuft === punkt.key}
|
||||||
|
kommentarOffen={kommentarOffen.has(punkt.key)}
|
||||||
|
onKommentarOeffnen={() =>
|
||||||
|
setKommentarOffen((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(punkt.key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSpeichern={(teil) => speichern(punkt.key, teil)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Zeile({
|
||||||
|
punkt,
|
||||||
|
stand,
|
||||||
|
laeuft,
|
||||||
|
kommentarOffen,
|
||||||
|
onKommentarOeffnen,
|
||||||
|
onSpeichern,
|
||||||
|
}: {
|
||||||
|
punkt: AufgabenPunkt;
|
||||||
|
stand: AufgabenStand | undefined;
|
||||||
|
laeuft: boolean;
|
||||||
|
kommentarOffen: boolean;
|
||||||
|
onKommentarOeffnen: () => void;
|
||||||
|
onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void;
|
||||||
|
}) {
|
||||||
|
const erledigt = istErledigt(punkt, stand);
|
||||||
|
const [text, setText] = useState(stand?.wert ?? "");
|
||||||
|
const [kommentar, setKommentar] = useState(stand?.kommentar ?? "");
|
||||||
|
const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="py-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className={`text-sm ${erledigt ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}`}>
|
||||||
|
{punkt.label}
|
||||||
|
</span>
|
||||||
|
{punkt.hinweis && <span className="block text-xs text-ink-muted">{punkt.hinweis}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{punkt.art === "haken" && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={punkt.label}
|
||||||
|
checked={stand?.erledigt ?? false}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(e) => onSpeichern({ erledigt: e.target.checked })}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-border text-brand-600
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.art === "janein" && (
|
||||||
|
// Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund,
|
||||||
|
// „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen.
|
||||||
|
<span className="flex shrink-0 gap-1">
|
||||||
|
{(["ja", "nein"] as const).map((wert) => (
|
||||||
|
<button
|
||||||
|
key={wert}
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft}
|
||||||
|
aria-pressed={stand?.wert === wert}
|
||||||
|
onClick={() => onSpeichern({ wert: stand?.wert === wert ? null : wert })}
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||||
|
stand?.wert === wert ? "bg-brand-500 text-white" : "bg-surface text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{wert === "ja" ? "Ja" : "Nein"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{punkt.art === "text" && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`${punkt.label} — Wert`}
|
||||||
|
value={text}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
|
||||||
|
placeholder="Wert"
|
||||||
|
className="w-28 shrink-0 rounded border border-border px-2 py-1 text-sm text-ink
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!zeigeKommentar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onKommentarOeffnen}
|
||||||
|
aria-label={`Kommentar zu ${punkt.label}`}
|
||||||
|
className="shrink-0 rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zeigeKommentar && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Kommentar zu ${punkt.label}`}
|
||||||
|
value={kommentar}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(e) => setKommentar(e.target.value)}
|
||||||
|
onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })}
|
||||||
|
placeholder="Kommentar …"
|
||||||
|
className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der
|
||||||
|
Haken stand. */}
|
||||||
|
{stand?.updated_by_name && (erledigt || stand.kommentar) && (
|
||||||
|
<p className="mt-1 text-[11px] text-ink-muted">
|
||||||
|
{stand.updated_by_name}
|
||||||
|
{stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
components/employees/tabs/OffboardingTab.tsx
Normal file
32
components/employees/tabs/OffboardingTab.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { setOffboardingTask, startOffboarding } from "@/actions/employees";
|
||||||
|
import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
|
||||||
|
import type { AufgabenStand } from "@/lib/checklist";
|
||||||
|
import { ChecklistPanel } from "./ChecklistPanel";
|
||||||
|
|
||||||
|
// Dünne Bindung — siehe OnboardingTab.tsx, dieselbe Mechanik für den anderen
|
||||||
|
// Weg.
|
||||||
|
|
||||||
|
export function OffboardingTab({
|
||||||
|
employeeId,
|
||||||
|
staende,
|
||||||
|
vorhanden,
|
||||||
|
}: {
|
||||||
|
employeeId: string;
|
||||||
|
staende: AufgabenStand[];
|
||||||
|
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Austritt. */
|
||||||
|
vorhanden: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ChecklistPanel
|
||||||
|
gruppen={OFFBOARDING_GRUPPEN}
|
||||||
|
staende={staende}
|
||||||
|
vorhanden={vorhanden}
|
||||||
|
leerText="Für diese Person gibt es keine Offboarding-Checkliste. Für einen No Show wird keine angelegt — wer nie angetreten ist, hat nichts offzuboarden."
|
||||||
|
anlegenLabel="Checkliste anlegen"
|
||||||
|
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||||
|
onAnlegen={() => startOffboarding(employeeId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,243 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageSquarePlus } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
|
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import type { AufgabenStand } from "@/lib/checklist";
|
||||||
import { fmtDate } from "@/lib/format";
|
import { ChecklistPanel } from "./ChecklistPanel";
|
||||||
import {
|
|
||||||
ONBOARDING_GRUPPEN,
|
|
||||||
fortschritt,
|
|
||||||
istErledigt,
|
|
||||||
type AufgabenPunkt,
|
|
||||||
type AufgabenStand,
|
|
||||||
} from "@/lib/onboarding";
|
|
||||||
|
|
||||||
// Die Checkliste, die bisher ein Blatt Papier war.
|
// Dünne Bindung: die Mechanik der Checkliste steht in ChecklistPanel, geteilt
|
||||||
//
|
// mit OffboardingTab. Hier steht nur, welches Verzeichnis gilt und welche
|
||||||
// Zwei Dinge macht sie anders als das Blatt, und beide sind der Grund, sie
|
// Server-Aktionen es speichern.
|
||||||
// überhaupt zu bauen: sie sagt, **wer** wann abgehakt hat — auf dem Blatt
|
|
||||||
// stand nur ein Haken —, und sie lässt sich vertreten, weil sie nicht in
|
|
||||||
// einer Schublade liegt.
|
|
||||||
//
|
|
||||||
// Gespeichert wird beim Klick, nicht beim Absenden. Eine Checkliste wird über
|
|
||||||
// Tage abgearbeitet, oft zwischen zwei anderen Dingen; ein „Speichern"-Knopf
|
|
||||||
// am Ende wäre die Stelle, an der ein halber Vormittag verlorengeht.
|
|
||||||
|
|
||||||
type Props = {
|
export function OnboardingTab({
|
||||||
|
employeeId,
|
||||||
|
staende,
|
||||||
|
vorhanden,
|
||||||
|
}: {
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
staende: AufgabenStand[];
|
staende: AufgabenStand[];
|
||||||
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
|
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
|
||||||
vorhanden: boolean;
|
vorhanden: boolean;
|
||||||
};
|
|
||||||
|
|
||||||
export function OnboardingTab({ employeeId, staende, vorhanden }: Props) {
|
|
||||||
const { showToast } = useToast();
|
|
||||||
const router = useRouter();
|
|
||||||
const [laeuft, setLaeuft] = useState<string | null>(null);
|
|
||||||
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
|
||||||
const stand = fortschritt(staende);
|
|
||||||
|
|
||||||
async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) {
|
|
||||||
setLaeuft(key);
|
|
||||||
const ergebnis = await setOnboardingTask({ employee_id: employeeId, item_key: key, ...teil });
|
|
||||||
setLaeuft(null);
|
|
||||||
if (ergebnis.success) router.refresh();
|
|
||||||
else showToast(ergebnis.error ?? "Konnte nicht gespeichert werden.", "error");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listeAnlegen() {
|
|
||||||
setLaeuft("neu");
|
|
||||||
const ergebnis = await startOnboarding(employeeId);
|
|
||||||
setLaeuft(null);
|
|
||||||
if (ergebnis.success) {
|
|
||||||
showToast("Checkliste angelegt.");
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!vorhanden) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-start gap-3">
|
|
||||||
<p className="text-sm text-ink-body">
|
|
||||||
Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder
|
|
||||||
Wiedereinstellung — wer davor eingetreten ist, hat keine.
|
|
||||||
</p>
|
|
||||||
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
|
|
||||||
Checkliste anlegen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const anteil = Math.round((stand.erledigt / stand.gesamt) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="rounded border border-border bg-surface px-3 py-2.5">
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<span className="text-sm font-semibold text-ink">
|
|
||||||
{stand.erledigt} von {stand.gesamt} erledigt
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-ink-muted">
|
|
||||||
{stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`}
|
|
||||||
{stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full ${anteil === 100 ? "bg-success-text" : "bg-brand-500"}`}
|
|
||||||
style={{ width: `${Math.max(2, anteil)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ONBOARDING_GRUPPEN.map((gruppe) => (
|
|
||||||
<div key={gruppe.key}>
|
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">{gruppe.label}</h3>
|
|
||||||
<ul className="mt-2 flex flex-col divide-y divide-border-subtle">
|
|
||||||
{gruppe.punkte.map((punkt) => (
|
|
||||||
<Zeile
|
|
||||||
key={punkt.key}
|
|
||||||
punkt={punkt}
|
|
||||||
stand={karte.get(punkt.key)}
|
|
||||||
laeuft={laeuft === punkt.key}
|
|
||||||
kommentarOffen={kommentarOffen.has(punkt.key)}
|
|
||||||
onKommentarOeffnen={() =>
|
|
||||||
setKommentarOffen((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(punkt.key);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onSpeichern={(teil) => speichern(punkt.key, teil)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Zeile({
|
|
||||||
punkt,
|
|
||||||
stand,
|
|
||||||
laeuft,
|
|
||||||
kommentarOffen,
|
|
||||||
onKommentarOeffnen,
|
|
||||||
onSpeichern,
|
|
||||||
}: {
|
|
||||||
punkt: AufgabenPunkt;
|
|
||||||
stand: AufgabenStand | undefined;
|
|
||||||
laeuft: boolean;
|
|
||||||
kommentarOffen: boolean;
|
|
||||||
onKommentarOeffnen: () => void;
|
|
||||||
onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const erledigt = istErledigt(punkt, stand);
|
|
||||||
const [text, setText] = useState(stand?.wert ?? "");
|
|
||||||
const [kommentar, setKommentar] = useState(stand?.kommentar ?? "");
|
|
||||||
const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="py-2.5">
|
<ChecklistPanel
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
gruppen={ONBOARDING_GRUPPEN}
|
||||||
<div className="min-w-0 flex-1">
|
staende={staende}
|
||||||
<span className={`text-sm ${erledigt ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}`}>
|
vorhanden={vorhanden}
|
||||||
{punkt.label}
|
leerText="Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder Wiedereinstellung — wer davor eingetreten ist, hat keine."
|
||||||
</span>
|
anlegenLabel="Checkliste anlegen"
|
||||||
{punkt.hinweis && <span className="block text-xs text-ink-muted">{punkt.hinweis}</span>}
|
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||||
</div>
|
onAnlegen={() => startOnboarding(employeeId)}
|
||||||
|
|
||||||
{punkt.art === "haken" && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={punkt.label}
|
|
||||||
checked={stand?.erledigt ?? false}
|
|
||||||
disabled={laeuft}
|
|
||||||
onChange={(e) => onSpeichern({ erledigt: e.target.checked })}
|
|
||||||
className="h-4 w-4 shrink-0 rounded border-border text-brand-600
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{punkt.art === "janein" && (
|
|
||||||
// Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund,
|
|
||||||
// „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen.
|
|
||||||
<span className="flex shrink-0 gap-1">
|
|
||||||
{(["ja", "nein"] as const).map((wert) => (
|
|
||||||
<button
|
|
||||||
key={wert}
|
|
||||||
type="button"
|
|
||||||
disabled={laeuft}
|
|
||||||
aria-pressed={stand?.wert === wert}
|
|
||||||
onClick={() => onSpeichern({ wert: stand?.wert === wert ? null : wert })}
|
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
|
||||||
stand?.wert === wert ? "bg-brand-500 text-white" : "bg-surface text-ink-muted hover:text-ink"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{wert === "ja" ? "Ja" : "Nein"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{punkt.art === "text" && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
aria-label={`${punkt.label} — Grösse`}
|
|
||||||
value={text}
|
|
||||||
disabled={laeuft}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
|
|
||||||
placeholder="Grösse"
|
|
||||||
className="w-24 shrink-0 rounded border border-border px-2 py-1 text-sm text-ink
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!zeigeKommentar && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onKommentarOeffnen}
|
|
||||||
aria-label={`Kommentar zu ${punkt.label}`}
|
|
||||||
className="shrink-0 rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{zeigeKommentar && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
aria-label={`Kommentar zu ${punkt.label}`}
|
|
||||||
value={kommentar}
|
|
||||||
disabled={laeuft}
|
|
||||||
onChange={(e) => setKommentar(e.target.value)}
|
|
||||||
onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })}
|
|
||||||
placeholder="Kommentar …"
|
|
||||||
className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der
|
|
||||||
Haken stand. */}
|
|
||||||
{stand?.updated_by_name && (erledigt || stand.kommentar) && (
|
|
||||||
<p className="mt-1 text-[11px] text-ink-muted">
|
|
||||||
{stand.updated_by_name}
|
|
||||||
{stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
98
lib/checklist.ts
Normal file
98
lib/checklist.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Der gemeinsame Kern einer personenbezogenen Checkliste.
|
||||||
|
//
|
||||||
|
// Onboarding und Offboarding sind zwei Listen mit demselben Verhalten: Punkte
|
||||||
|
// mit einer Art, ein gespeicherter Stand je Person und Punkt, ein Fortschritt
|
||||||
|
// daraus. Dieselbe Logik zweimal abzuschreiben wäre die Art Duplikat, die
|
||||||
|
// nicht laut scheitert, sondern leise auseinanderläuft — eine Änderung an der
|
||||||
|
// einen Stelle, die die andere nie erreicht. Deshalb steht sie hier einmal,
|
||||||
|
// und lib/onboarding.ts wie lib/offboarding.ts binden sie nur an ihr eigenes
|
||||||
|
// Verzeichnis.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten:
|
||||||
|
*
|
||||||
|
* • `haken` — erledigt oder nicht. Der Normalfall: ein Beleg ist da oder
|
||||||
|
* fehlt, ein Zwischenzustand ist nicht vorgesehen.
|
||||||
|
* • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes
|
||||||
|
* heisst als **nein**. „nein" ist ein erhobener Befund, „noch nicht
|
||||||
|
* gefragt" nicht — ein einzelner Haken könnte das nicht sagen.
|
||||||
|
* • `text` — ein Wert, der festgehalten wird. Bei einer Grösse oder einem
|
||||||
|
* Resturlaubssaldo ist die Angabe selbst der Zweck; abgehakt wäre sie
|
||||||
|
* wertlos.
|
||||||
|
*/
|
||||||
|
export type AufgabenArt = "haken" | "janein" | "text";
|
||||||
|
|
||||||
|
export type AufgabenPunkt = {
|
||||||
|
/** Bleibt stabil — er steht in den Daten. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
art: AufgabenArt;
|
||||||
|
/** Zusatz unter der Beschriftung, wo die Bezeichnung allein nicht reicht. */
|
||||||
|
hinweis?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AufgabenGruppe = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
punkte: readonly AufgabenPunkt[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Der gespeicherte Stand eines Punktes. */
|
||||||
|
export type AufgabenStand = {
|
||||||
|
item_key: string;
|
||||||
|
erledigt: boolean;
|
||||||
|
/** Bei `janein` „ja"/„nein", bei `text` die Angabe, bei `haken` leer. */
|
||||||
|
wert: string | null;
|
||||||
|
kommentar: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] };
|
||||||
|
|
||||||
|
/** Aus einer flachen Liste von Gruppen die Punkte allein — mehrfach gebraucht. */
|
||||||
|
export function alleReichungen(gruppen: readonly AufgabenGruppe[]): readonly AufgabenPunkt[] {
|
||||||
|
return gruppen.flatMap((g) => g.punkte);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function punktZuVon(punkte: readonly AufgabenPunkt[], key: string): AufgabenPunkt | undefined {
|
||||||
|
// Keine Karte vorab: die Verzeichnisse sind klein (ein bis zwei Dutzend
|
||||||
|
// Punkte), und eine Suche bei jedem Aufruf ist hier billiger als der
|
||||||
|
// Aufwand, zwei Module je eine eigene, versteckte Karte pflegen zu lassen.
|
||||||
|
return punkte.find((p) => p.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer
|
||||||
|
* Stelle statt an mehreren.
|
||||||
|
*
|
||||||
|
* Bei `janein` zählt auch „nein" als erledigt: die Frage ist beantwortet, und
|
||||||
|
* genau das ist die Aufgabe. Bei `text` ist die Angabe selbst die Erledigung;
|
||||||
|
* ein leerer Text ist keine.
|
||||||
|
*/
|
||||||
|
export function istErledigt(punkt: AufgabenPunkt, stand: AufgabenStand | undefined): boolean {
|
||||||
|
if (!stand) return false;
|
||||||
|
if (punkt.art === "haken") return stand.erledigt;
|
||||||
|
return (stand.wert ?? "").trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie weit eine Liste ist. `offen` nennt die Beschriftungen, nicht die
|
||||||
|
* Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird.
|
||||||
|
*/
|
||||||
|
export function fortschrittVon(punkte: readonly AufgabenPunkt[], staende: AufgabenStand[]): Fortschritt {
|
||||||
|
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
||||||
|
const offen = punkte.filter((p) => !istErledigt(p, karte.get(p.key)));
|
||||||
|
return {
|
||||||
|
erledigt: punkte.length - offen.length,
|
||||||
|
gesamt: punkte.length,
|
||||||
|
offen: offen.map((p) => p.label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was als Wert erlaubt ist — dieselbe Prüfung wie in der Datenbank. */
|
||||||
|
export function wertIstGueltig(punkt: AufgabenPunkt, wert: string | null): boolean {
|
||||||
|
if (punkt.art === "janein") return wert === null || wert === "ja" || wert === "nein";
|
||||||
|
if (punkt.art === "haken") return wert === null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -96,6 +96,13 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
|||||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||||
.where("employee_id", "=", id)
|
.where("employee_id", "=", id)
|
||||||
).as("onboarding"),
|
).as("onboarding"),
|
||||||
|
jsonArrayFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom("offboarding_tasks")
|
||||||
|
.select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"])
|
||||||
|
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||||
|
.where("employee_id", "=", id)
|
||||||
|
).as("offboarding"),
|
||||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||||
// Organisation, nicht zu dieser Person.
|
// Organisation, nicht zu dieser Person.
|
||||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||||
@@ -139,6 +146,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
|||||||
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
|
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
|
||||||
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
|
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
|
||||||
onboarding: g.onboarding,
|
onboarding: g.onboarding,
|
||||||
|
offboarding: g.offboarding,
|
||||||
openPositions,
|
openPositions,
|
||||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||||
};
|
};
|
||||||
|
|||||||
80
lib/offboarding.ts
Normal file
80
lib/offboarding.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
alleReichungen,
|
||||||
|
fortschrittVon,
|
||||||
|
punktZuVon,
|
||||||
|
type AufgabenGruppe,
|
||||||
|
type AufgabenPunkt,
|
||||||
|
type AufgabenStand,
|
||||||
|
type Fortschritt,
|
||||||
|
} from "./checklist";
|
||||||
|
|
||||||
|
export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist";
|
||||||
|
export { istErledigt, wertIstGueltig } from "./checklist";
|
||||||
|
|
||||||
|
// Die Offboarding-Checkliste — vom selben Blatt Papier wie die Onboarding-
|
||||||
|
// Liste, nur für den anderen Weg. Was dort steht, ist hier eins zu eins
|
||||||
|
// übernommen; wo die Bezeichnung „GKK" auf dem Blatt steht statt des heute
|
||||||
|
// gebräuchlichen „ÖGK", ist sie unverändert stehengeblieben — das ist der
|
||||||
|
// Name, unter dem der Vorgang im Haus läuft, nicht ein Tippfehler.
|
||||||
|
//
|
||||||
|
// Zwei Punkte auf dem Blatt tragen denselben Namen zweimal: „Überleitung
|
||||||
|
// Salden für Auszahlung" steht einmal als abzuhakende Aufgabe und daneben als
|
||||||
|
// Feld für den tatsächlichen Betrag. Das ist kein Versehen im Blatt, sondern
|
||||||
|
// zwei verschiedene Fragen — „ist es veranlasst?" und „wie viel ist es?" —,
|
||||||
|
// die hier auch zwei verschiedene Punkte sind.
|
||||||
|
//
|
||||||
|
// Warum kein Ja/Nein-Punkt hier steht (anders als Prämienanspruch beim
|
||||||
|
// Onboarding): auf diesem Blatt gibt es keine Stelle mit zwei Kästchen
|
||||||
|
// nebeneinander. Taucht künftig eine auf, ist `art: "janein"` bereit.
|
||||||
|
|
||||||
|
export const OFFBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||||
|
{
|
||||||
|
key: "aufgaben",
|
||||||
|
label: "Zu erledigende Aufgaben",
|
||||||
|
punkte: [
|
||||||
|
{ key: "br_verstaendigung", label: "BR Verständigung bei KDG", art: "haken" },
|
||||||
|
{ key: "kuendigungsschreiben", label: "Kündigungsschreiben (KDG)", art: "haken" },
|
||||||
|
{ key: "bestaetigung_kdn_bei", label: "Bestätigung KDN / BEI", art: "haken" },
|
||||||
|
{ key: "lgv_austritt", label: "LGV Austritt", art: "haken" },
|
||||||
|
{ key: "gkk_abmeldung", label: "GKK-Abmeldung", art: "haken" },
|
||||||
|
{ key: "if6020_austritt", label: "IF 6020 Austritt", art: "haken" },
|
||||||
|
{ key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" },
|
||||||
|
{ key: "endabrechnung", label: "Endabrechnung", art: "haken" },
|
||||||
|
{ key: "offboarding_gestartet", label: "Offboarding gestartet", art: "haken" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vermerke",
|
||||||
|
label: "Vermerke",
|
||||||
|
punkte: [
|
||||||
|
{ key: "resturlaub", label: "Resturlaub", art: "text" },
|
||||||
|
{ key: "ueberleitung_salden_betrag", label: "Überleitung Salden für Auszahlung — Betrag", art: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OFFBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(OFFBOARDING_GRUPPEN);
|
||||||
|
|
||||||
|
export function punktZu(key: string): AufgabenPunkt | undefined {
|
||||||
|
return punktZuVon(OFFBOARDING_PUNKTE, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fortschritt(staende: AufgabenStand[]): Fortschritt {
|
||||||
|
return fortschrittVon(OFFBOARDING_PUNKTE, staende);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob eine Person offzuboarden ist — die Regel hinter dem Reiter.
|
||||||
|
*
|
||||||
|
* Nur ein echter Austritt zählt. No Show scheidet zwar mit demselben Status
|
||||||
|
* „Ausgetreten" aus, hat aber nie einen Tag gearbeitet: keinen IT-Zugang, der
|
||||||
|
* eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Ein Reiter mit
|
||||||
|
* einer leeren Liste wäre dort ein Etikett ohne Inhalt.
|
||||||
|
*
|
||||||
|
* Wird die Person wiedereingestellt, wechselt der Status weg von
|
||||||
|
* „Ausgetreten", und die Regel verneint wieder — die Checkliste bleibt in den
|
||||||
|
* Daten stehen, nur ihr Reiter verschwindet, solange sie ohne Gegenstand ist.
|
||||||
|
*/
|
||||||
|
export function gehoertOffboarding(employee: { status: string; exit_reason: string | null }): boolean {
|
||||||
|
return employee.status === "Ausgetreten" && employee.exit_reason !== "No Show";
|
||||||
|
}
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
|
import {
|
||||||
|
alleReichungen,
|
||||||
|
fortschrittVon,
|
||||||
|
punktZuVon,
|
||||||
|
type AufgabenGruppe,
|
||||||
|
type AufgabenPunkt,
|
||||||
|
type AufgabenStand,
|
||||||
|
type Fortschritt,
|
||||||
|
} from "./checklist";
|
||||||
|
|
||||||
|
export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist";
|
||||||
|
// istErledigt/wertIstGueltig nehmen bereits einen Punkt entgegen, nicht den
|
||||||
|
// Schlüssel — sie brauchen keine Bindung an dieses Verzeichnis und werden
|
||||||
|
// unverändert weitergereicht.
|
||||||
|
export { istErledigt, wertIstGueltig } from "./checklist";
|
||||||
|
|
||||||
// Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt
|
// Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt
|
||||||
// und Auswertung gemeinsam lesen.
|
// und Auswertung gemeinsam lesen.
|
||||||
//
|
//
|
||||||
@@ -11,35 +27,10 @@
|
|||||||
// Schlüssel des Punktes. Ein später entfernter Punkt lässt die alten Antworten
|
// Schlüssel des Punktes. Ein später entfernter Punkt lässt die alten Antworten
|
||||||
// deshalb stehen — sie verschwinden aus der Liste, aber nicht aus den Daten,
|
// deshalb stehen — sie verschwinden aus der Liste, aber nicht aus den Daten,
|
||||||
// und eine Akte von damals bleibt lesbar.
|
// und eine Akte von damals bleibt lesbar.
|
||||||
|
//
|
||||||
/**
|
// Die Mechanik selbst (Fortschritt, was als erledigt zählt) steht in
|
||||||
* Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten:
|
// lib/checklist.ts — geteilt mit lib/offboarding.ts, damit beide Listen
|
||||||
*
|
// dasselbe „erledigt" meinen und nicht mit der Zeit auseinanderlaufen.
|
||||||
* • `haken` — erledigt oder nicht. Der Normalfall: „Meldezettel" ist da oder
|
|
||||||
* fehlt, ein Zwischenzustand ist nicht vorgesehen.
|
|
||||||
* • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes
|
|
||||||
* heisst als **nein**. Beim Prämienanspruch stehen im Blatt zwei Kästchen
|
|
||||||
* nebeneinander, und das ist kein Zufall: „nein" ist ein erhobener Befund,
|
|
||||||
* „noch nicht gefragt" nicht. Ein einzelner Haken könnte das nicht sagen.
|
|
||||||
* • `text` — ein Wert, der festgehalten wird. Bei den Grössen ist die Angabe
|
|
||||||
* selbst der Zweck; abgehakt wäre sie wertlos.
|
|
||||||
*/
|
|
||||||
export type AufgabenArt = "haken" | "janein" | "text";
|
|
||||||
|
|
||||||
export type AufgabenPunkt = {
|
|
||||||
/** Bleibt stabil — er steht in den Daten. */
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
art: AufgabenArt;
|
|
||||||
/** Zusatz unter der Beschriftung, wo die Bezeichnung allein nicht reicht. */
|
|
||||||
hinweis?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AufgabenGruppe = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
punkte: readonly AufgabenPunkt[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||||
{
|
{
|
||||||
@@ -97,58 +88,12 @@ export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = ONBOARDING_GRUPPEN.flatMap((g) => g.punkte);
|
export const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(ONBOARDING_GRUPPEN);
|
||||||
|
|
||||||
const NACH_KEY = new Map(ONBOARDING_PUNKTE.map((p) => [p.key, p]));
|
|
||||||
|
|
||||||
export function punktZu(key: string): AufgabenPunkt | undefined {
|
export function punktZu(key: string): AufgabenPunkt | undefined {
|
||||||
return NACH_KEY.get(key);
|
return punktZuVon(ONBOARDING_PUNKTE, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der gespeicherte Stand eines Punktes. */
|
|
||||||
export type AufgabenStand = {
|
|
||||||
item_key: string;
|
|
||||||
erledigt: boolean;
|
|
||||||
/** Bei `janein` „ja"/„nein", bei `text` die Angabe, bei `haken` leer. */
|
|
||||||
wert: string | null;
|
|
||||||
kommentar: string | null;
|
|
||||||
updated_at: string | null;
|
|
||||||
updated_by_name: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer
|
|
||||||
* Stelle statt an dreien.
|
|
||||||
*
|
|
||||||
* Bei `janein` zählt auch „nein" als erledigt: die Frage ist beantwortet, und
|
|
||||||
* genau das ist die Aufgabe. Bei `text` ist die Angabe selbst die Erledigung;
|
|
||||||
* ein leerer Text ist keine.
|
|
||||||
*/
|
|
||||||
export function istErledigt(punkt: AufgabenPunkt, stand: AufgabenStand | undefined): boolean {
|
|
||||||
if (!stand) return false;
|
|
||||||
if (punkt.art === "haken") return stand.erledigt;
|
|
||||||
return (stand.wert ?? "").trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wie weit die Liste ist. `offen` nennt die Beschriftungen, nicht die
|
|
||||||
* Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird.
|
|
||||||
*/
|
|
||||||
export function fortschritt(staende: AufgabenStand[]): Fortschritt {
|
export function fortschritt(staende: AufgabenStand[]): Fortschritt {
|
||||||
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
return fortschrittVon(ONBOARDING_PUNKTE, staende);
|
||||||
const offen = ONBOARDING_PUNKTE.filter((p) => !istErledigt(p, karte.get(p.key)));
|
|
||||||
return {
|
|
||||||
erledigt: ONBOARDING_PUNKTE.length - offen.length,
|
|
||||||
gesamt: ONBOARDING_PUNKTE.length,
|
|
||||||
offen: offen.map((p) => p.label),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Was als Wert erlaubt ist — dieselbe Prüfung wie in der Datenbank. */
|
|
||||||
export function wertIstGueltig(punkt: AufgabenPunkt, wert: string | null): boolean {
|
|
||||||
if (punkt.art === "janein") return wert === null || wert === "ja" || wert === "nein";
|
|
||||||
if (punkt.art === "haken") return wert === null;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -508,6 +508,35 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["onboarding_tasks"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["onboarding_tasks"]["Insert"]>;
|
||||||
};
|
};
|
||||||
|
// Die Offboarding-Checkliste — dieselbe Form wie onboarding_tasks, für
|
||||||
|
// den anderen Weg. Punkte in lib/offboarding.ts.
|
||||||
|
offboarding_tasks: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
item_key: string;
|
||||||
|
erledigt: boolean;
|
||||||
|
wert: string | null;
|
||||||
|
kommentar: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string | null;
|
||||||
|
updated_by_name: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
employee_id: string;
|
||||||
|
item_key: string;
|
||||||
|
erledigt?: boolean;
|
||||||
|
wert?: string | null;
|
||||||
|
kommentar?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
updated_by?: string | null;
|
||||||
|
updated_by_name?: string | null;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["offboarding_tasks"]["Insert"]>;
|
||||||
|
};
|
||||||
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
|
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
|
||||||
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
|
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
|
||||||
cost_centers: NoRelationships & {
|
cost_centers: NoRelationships & {
|
||||||
@@ -616,6 +645,8 @@ export type Database = {
|
|||||||
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
start_onboarding: { Args: { payload: Record<string, unknown> }; Returns: number };
|
start_onboarding: { Args: { payload: Record<string, unknown> }; Returns: number };
|
||||||
|
set_offboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
start_offboarding: { Args: { payload: Record<string, unknown> }; Returns: number };
|
||||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||||
|
|||||||
148
supabase/migrations/20260818100000_offboarding_checkliste.sql
Normal file
148
supabase/migrations/20260818100000_offboarding_checkliste.sql
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
-- Offboarding-Checkliste je Person
|
||||||
|
--
|
||||||
|
-- Dieselbe Sache wie onboarding_tasks (20260817100000), für den anderen Weg:
|
||||||
|
-- je Person und Punkt eine Zeile, der Schlüssel als Text statt als Fremd-
|
||||||
|
-- schlüssel, damit ein später gestrichener Punkt seine alten Antworten
|
||||||
|
-- stehen lässt statt sie mitzureissen. Die Begründung im Einzelnen steht bei
|
||||||
|
-- der Onboarding-Migration; hier nur, was abweicht.
|
||||||
|
--
|
||||||
|
-- Eine eigene Tabelle statt einer gemeinsamen mit Spalte „Richtung": beide
|
||||||
|
-- Listen haben eigene Punkte (lib/onboarding.ts, lib/offboarding.ts), einen
|
||||||
|
-- eigenen Anlass (Ein-, Aus- oder Wiedereintritt) und eigene Protokollzeilen.
|
||||||
|
-- Eine gemeinsame Tabelle würde das mit einer Fallunterscheidung nachbauen,
|
||||||
|
-- die schon in zwei Tabellen und zwei Funktionen so nicht existiert.
|
||||||
|
|
||||||
|
create table if not exists offboarding_tasks (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
employee_id uuid not null references employees(id) on delete cascade,
|
||||||
|
item_key text not null,
|
||||||
|
erledigt boolean not null default false,
|
||||||
|
wert text,
|
||||||
|
kommentar text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
updated_by uuid references profiles(id) on delete set null,
|
||||||
|
updated_by_name text,
|
||||||
|
constraint offboarding_tasks_eine_zeile unique (employee_id, item_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists offboarding_tasks_employee_id_idx on offboarding_tasks (employee_id);
|
||||||
|
create index if not exists offboarding_tasks_offen_idx on offboarding_tasks (employee_id) where not erledigt;
|
||||||
|
|
||||||
|
drop policy if exists offboarding_tasks_hr_all on offboarding_tasks;
|
||||||
|
create policy offboarding_tasks_hr_all on offboarding_tasks
|
||||||
|
for all using (is_hr_user()) with check (is_hr_user());
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
|
||||||
|
-- Wortgleich mit set_onboarding_task, nur auf die andere Tabelle bezogen.
|
||||||
|
create or replace function set_offboarding_task(payload jsonb)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path to 'public', 'pg_temp'
|
||||||
|
as $function$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_item_key text := nullif(trim(payload->>'item_key'), '');
|
||||||
|
v_name text;
|
||||||
|
v_vorher offboarding_tasks%rowtype;
|
||||||
|
v_erledigt boolean;
|
||||||
|
v_wert text;
|
||||||
|
v_kommentar text;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
|
||||||
|
if v_item_key is null then
|
||||||
|
raise exception 'Es wurde kein Punkt angegeben.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||||
|
if v_name is null then
|
||||||
|
raise exception 'Die Person existiert nicht.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v_vorher from offboarding_tasks
|
||||||
|
where employee_id = v_employee_id and item_key = v_item_key for update;
|
||||||
|
|
||||||
|
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
|
||||||
|
else coalesce(v_vorher.erledigt, false) end;
|
||||||
|
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
|
||||||
|
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
|
||||||
|
else v_vorher.kommentar end;
|
||||||
|
|
||||||
|
insert into offboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
|
||||||
|
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
|
||||||
|
app_current_user_id(), current_actor_name())
|
||||||
|
on conflict (employee_id, item_key) do update
|
||||||
|
set erledigt = excluded.erledigt,
|
||||||
|
wert = excluded.wert,
|
||||||
|
kommentar = excluded.kommentar,
|
||||||
|
updated_at = now(),
|
||||||
|
updated_by = excluded.updated_by,
|
||||||
|
updated_by_name = excluded.updated_by_name;
|
||||||
|
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Offboarding-Punkt', v_name, v_employee_id,
|
||||||
|
v_item_key || ': ' ||
|
||||||
|
case when v_wert is not null then v_wert
|
||||||
|
when v_erledigt then 'erledigt'
|
||||||
|
else 'offen' end ||
|
||||||
|
coalesce(' — ' || v_kommentar, ''));
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
revoke all on function set_offboarding_task(jsonb) from public;
|
||||||
|
grant execute on function set_offboarding_task(jsonb) to public;
|
||||||
|
|
||||||
|
|
||||||
|
-- ── Die Liste anlegen ──────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Anders als beim Onboarding steht dieser Aufruf nicht für jeden Austritt in
|
||||||
|
-- derselben Transaktion, die den Status setzt: bei einem No Show — wer nie
|
||||||
|
-- angetreten ist — gibt es nichts offzuboarden, es war nie ein IT-Zugang
|
||||||
|
-- eingerichtet, keine GKK-Anmeldung, kein Dienstzettel. Diese Funktion prüft
|
||||||
|
-- das nicht selbst; die aufrufende Seite (actions/employees.ts) entscheidet,
|
||||||
|
-- wann sie gerufen wird. Sie existiert trotzdem als eigene, aufrufbare
|
||||||
|
-- Funktion — für den Nachtrag bei einem Austritt von vor dieser Liste.
|
||||||
|
create or replace function start_offboarding(payload jsonb)
|
||||||
|
returns integer
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path to 'public', 'pg_temp'
|
||||||
|
as $function$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
|
||||||
|
v_name text;
|
||||||
|
v_angelegt integer;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
|
||||||
|
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||||
|
if v_name is null then
|
||||||
|
raise exception 'Die Person existiert nicht.';
|
||||||
|
end if;
|
||||||
|
if jsonb_array_length(v_keys) = 0 then
|
||||||
|
raise exception 'Es wurden keine Punkte übergeben.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into offboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
|
||||||
|
select v_employee_id, k, app_current_user_id(), current_actor_name()
|
||||||
|
from jsonb_array_elements_text(v_keys) k
|
||||||
|
on conflict (employee_id, item_key) do nothing;
|
||||||
|
|
||||||
|
get diagnostics v_angelegt = row_count;
|
||||||
|
|
||||||
|
if v_angelegt > 0 then
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Offboarding-Checkliste angelegt', v_name, v_employee_id,
|
||||||
|
v_angelegt || ' Punkte');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_angelegt;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
revoke all on function start_offboarding(jsonb) from public;
|
||||||
|
grant execute on function start_offboarding(jsonb) to public;
|
||||||
@@ -122,12 +122,16 @@ describe("TerminatePanel — Nichtantritt", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => {
|
it("kündigt die Offboarding-Checkliste nur für einen echten Austritt an", async () => {
|
||||||
|
// Die Liste selbst entsteht jetzt in derselben Transaktion wie der
|
||||||
|
// Austritt (actions/employees.ts) und lebt im Reiter „Offboarding" — hier
|
||||||
|
// steht nur die Ankündigung. Bei No Show fehlt sie: die Person hat nie
|
||||||
|
// angefangen, es gibt nichts offzuboarden.
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
zeige({ status: "Aktiv" });
|
zeige({ status: "Aktiv" });
|
||||||
expect(screen.getByText("Offboarding-Checkliste")).toBeVisible();
|
expect(screen.getByText(/Reiter „Offboarding“/)).toBeVisible();
|
||||||
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
||||||
expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible();
|
expect(screen.queryByText(/Reiter „Offboarding“/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sagt, was mit der Planstelle passiert", async () => {
|
it("sagt, was mit der Planstelle passiert", async () => {
|
||||||
|
|||||||
115
tests/unit/offboarding.test.ts
Normal file
115
tests/unit/offboarding.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
OFFBOARDING_GRUPPEN,
|
||||||
|
OFFBOARDING_PUNKTE,
|
||||||
|
fortschritt,
|
||||||
|
gehoertOffboarding,
|
||||||
|
istErledigt,
|
||||||
|
punktZu,
|
||||||
|
type AufgabenStand,
|
||||||
|
} from "@/lib/offboarding";
|
||||||
|
|
||||||
|
// Vom selben Blatt Papier wie die Onboarding-Liste, für den anderen Weg. Die
|
||||||
|
// Mechanik selbst (was als erledigt zählt, wie der Fortschritt entsteht) ist
|
||||||
|
// mit lib/onboarding.ts geteilt (lib/checklist.ts) und dort schon geprüft —
|
||||||
|
// hier geht es um das, was an dieser Liste besonders ist.
|
||||||
|
|
||||||
|
function stand(teil: Partial<AufgabenStand> & { item_key: string }): AufgabenStand {
|
||||||
|
return { erledigt: false, wert: null, kommentar: null, updated_at: null, updated_by_name: null, ...teil };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Das Verzeichnis", () => {
|
||||||
|
it("hat eindeutige Schlüssel — sie stehen in den Daten", () => {
|
||||||
|
const keys = OFFBOARDING_PUNKTE.map((p) => p.key);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bildet jeden Punkt aus dem Blatt ab", () => {
|
||||||
|
expect(OFFBOARDING_PUNKTE).toHaveLength(11);
|
||||||
|
for (const key of [
|
||||||
|
"br_verstaendigung",
|
||||||
|
"kuendigungsschreiben",
|
||||||
|
"bestaetigung_kdn_bei",
|
||||||
|
"lgv_austritt",
|
||||||
|
"gkk_abmeldung",
|
||||||
|
"if6020_austritt",
|
||||||
|
"endabrechnung",
|
||||||
|
"offboarding_gestartet",
|
||||||
|
]) {
|
||||||
|
expect(punktZu(key), key).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt die beiden Vermerke als Text, nicht als Haken", () => {
|
||||||
|
// Ein Resturlaubssaldo abgehakt wäre wertlos — die Zahl selbst ist der Zweck.
|
||||||
|
expect(punktZu("resturlaub")?.art).toBe("text");
|
||||||
|
expect(punktZu("ueberleitung_salden_betrag")?.art).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unterscheidet die Aufgabe von ihrem Betrag", () => {
|
||||||
|
// Das Blatt nennt „Überleitung Salden für Auszahlung" zweimal: einmal als
|
||||||
|
// abzuhakende Aufgabe, einmal als Feld für den tatsächlichen Betrag. Zwei
|
||||||
|
// verschiedene Fragen, zwei verschiedene Punkte.
|
||||||
|
expect(punktZu("ueberleitung_salden")?.art).toBe("haken");
|
||||||
|
expect(punktZu("ueberleitung_salden_betrag")?.art).toBe("text");
|
||||||
|
expect(punktZu("ueberleitung_salden")?.key).not.toBe(punktZu("ueberleitung_salden_betrag")?.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt keinen Ja/Nein-Punkt — anders als beim Onboarding", () => {
|
||||||
|
// Auf diesem Blatt gibt es keine Stelle mit zwei Kästchen nebeneinander.
|
||||||
|
expect(OFFBOARDING_PUNKTE.some((p) => p.art === "janein")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gruppiert vollständig — kein Punkt fällt zwischen die Gruppen", () => {
|
||||||
|
const ausGruppen = OFFBOARDING_GRUPPEN.flatMap((g) => g.punkte.map((p) => p.key));
|
||||||
|
expect(new Set(ausGruppen)).toEqual(new Set(OFFBOARDING_PUNKTE.map((p) => p.key)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fortschritt", () => {
|
||||||
|
it("ist auf einer leeren Liste vollständig offen", () => {
|
||||||
|
const f = fortschritt([]);
|
||||||
|
expect(f.erledigt).toBe(0);
|
||||||
|
expect(f.gesamt).toBe(OFFBOARDING_PUNKTE.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zählt Haken und Text zusammen", () => {
|
||||||
|
const f = fortschritt([
|
||||||
|
stand({ item_key: "endabrechnung", erledigt: true }),
|
||||||
|
stand({ item_key: "resturlaub", wert: "12 Tage" }),
|
||||||
|
]);
|
||||||
|
expect(f.erledigt).toBe(2);
|
||||||
|
expect(f.offen).not.toContain("Endabrechnung");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("istErledigt", () => {
|
||||||
|
it("verlangt bei einem Haken die ausdrückliche Markierung", () => {
|
||||||
|
const punkt = punktZu("gkk_abmeldung")!;
|
||||||
|
expect(istErledigt(punkt, stand({ item_key: punkt.key, erledigt: true }))).toBe(true);
|
||||||
|
expect(istErledigt(punkt, stand({ item_key: punkt.key }))).toBe(false);
|
||||||
|
expect(istErledigt(punkt, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gehoertOffboarding", () => {
|
||||||
|
// Die Regel hinter dem Reiter: er erscheint bei einem echten Austritt,
|
||||||
|
// nicht bei jedem, der denselben Status „Ausgetreten" trägt.
|
||||||
|
it("bejaht bei einem echten Austritt", () => {
|
||||||
|
expect(gehoertOffboarding({ status: "Ausgetreten", exit_reason: "Kündigung AN" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verneint bei No Show, obwohl der Status derselbe ist", () => {
|
||||||
|
// Nie einen Tag gearbeitet: kein IT-Zugang, keine GKK-Anmeldung, kein
|
||||||
|
// Dienstzettel. Der Reiter wäre ein Etikett ohne Inhalt.
|
||||||
|
expect(gehoertOffboarding({ status: "Ausgetreten", exit_reason: "No Show" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verneint für eine aktive Person", () => {
|
||||||
|
expect(gehoertOffboarding({ status: "Aktiv", exit_reason: null })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verneint für eine geplante Person", () => {
|
||||||
|
expect(gehoertOffboarding({ status: "Geplant", exit_reason: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user