Elf Punkte aus der Rueckmeldung, und die verlorene Anmerkung
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel "Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte (Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn. "Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der Behinderung und der Erklaertext ueber dem Honestly-Report sind weg. "Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler. Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern" wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse. "Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer "Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt. Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige. Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends. Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis 20260814100000 stand sie in der Beschreibung des Ereignisses; beim Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung faengt den naechsten Verlust ab.
This commit is contained in:
@@ -207,7 +207,7 @@ export default async function DashboardPage({
|
|||||||
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
|
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
|
||||||
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
|
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
|
||||||
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
||||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
{ label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||||
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
||||||
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
||||||
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
|
// Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht.
|
||||||
@@ -224,13 +224,14 @@ export default async function DashboardPage({
|
|||||||
titel: "Personalbewegung",
|
titel: "Personalbewegung",
|
||||||
kacheln: [
|
kacheln: [
|
||||||
{
|
{
|
||||||
label: "Eintritte (Jahr)",
|
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
|
||||||
|
label: "Eintritte (YTD)",
|
||||||
value: hiresYtd,
|
value: hiresYtd,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Austritte (Jahr)",
|
label: "Austritte (YTD)",
|
||||||
value: exitsYtd,
|
value: exitsYtd,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
@@ -363,7 +364,20 @@ export default async function DashboardPage({
|
|||||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
|
{/* Der Name führt in die Akte. Ohne den Link war die Karte
|
||||||
|
eine Sackgasse: man sah, dass etwas passiert ist, und
|
||||||
|
musste die Person danach in der Liste suchen. */}
|
||||||
|
{h.first_name && h.last_name ? (
|
||||||
|
<Link
|
||||||
|
href={`/employees/${h.employee_id}`}
|
||||||
|
className="rounded text-sm font-semibold text-ink hover:text-brand-700 hover:underline
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
{fmtName(h.first_name, h.last_name)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-semibold text-ink">Unbekannt</span>
|
||||||
|
)}
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
|
||||||
{h.event_type}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const ACTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||||||
{ value: "Vertragsänderung", label: "Vertragsänderung" },
|
{ value: "Vertragsänderung", label: "Vertragsänderung" },
|
||||||
{ value: "Stammdatenänderung", label: "Stammdatenänderung" },
|
{ value: "Stammdatenänderung", label: "Stammdatenänderung" },
|
||||||
{ value: "Gehaltsanpassung", label: "Gehaltsanpassung" },
|
// „Gehaltsanpassung" steht nicht mehr zur Auswahl: das Gehalt ist aus dem
|
||||||
|
// Funktionsumfang genommen (es wird in Loga geführt), keine Funktion
|
||||||
|
// schreibt diese Art mehr, und ein Filter, der immer leer ausgeht, sieht
|
||||||
|
// aus wie ein Fehler. Der Wert bleibt im Aufzählungstyp — alte Einträge
|
||||||
|
// aus der Zeit davor sollen lesbar bleiben.
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AuditFilters() {
|
export function AuditFilters() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TextField } from "@/components/ui/Field";
|
|||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { fmtDate } from "@/lib/format";
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import { auswahlFuer } from "@/lib/historie-felder";
|
||||||
import type { AuditChange } from "@/lib/types";
|
import type { AuditChange } from "@/lib/types";
|
||||||
|
|
||||||
// Berichtigen, nicht neu erfassen.
|
// Berichtigen, nicht neu erfassen.
|
||||||
@@ -181,6 +182,28 @@ export function HistorieBearbeiten({
|
|||||||
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
|
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
|
{/* Führt das Feld eine feste Liste, wird sie auch hier
|
||||||
|
angeboten: getippt entstünde sonst ein Wert, den keine
|
||||||
|
Auswertung mehr findet — die Berichtigung schreibt in
|
||||||
|
dieselbe Spalte wie das Formular, nur ohne dessen
|
||||||
|
Prüfung. Welche Felder das sind, steht in
|
||||||
|
lib/historie-felder.ts. */}
|
||||||
|
{auswahlFuer(c.feld) ? (
|
||||||
|
<select
|
||||||
|
aria-label={`${c.feld} — neuer Wert`}
|
||||||
|
value={werte[c.feld] ?? ""}
|
||||||
|
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
|
||||||
|
className="w-full rounded border border-border bg-white px-2 py-1 text-sm text-ink
|
||||||
|
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">leer</option>
|
||||||
|
{auswahlFuer(c.feld)!.map((w) => (
|
||||||
|
<option key={w} value={w}>
|
||||||
|
{w}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={`${c.feld} — neuer Wert`}
|
aria-label={`${c.feld} — neuer Wert`}
|
||||||
@@ -189,6 +212,7 @@ export function HistorieBearbeiten({
|
|||||||
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
|
className="w-full 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"
|
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function NotfallkontaktModal({
|
|||||||
const [name, setName] = useState(kontakt.name ?? "");
|
const [name, setName] = useState(kontakt.name ?? "");
|
||||||
const [telefon, setTelefon] = useState(kontakt.telefon ?? "");
|
const [telefon, setTelefon] = useState(kontakt.telefon ?? "");
|
||||||
const [verhaeltnis, setVerhaeltnis] = useState(kontakt.verhaeltnis ?? "");
|
const [verhaeltnis, setVerhaeltnis] = useState(kontakt.verhaeltnis ?? "");
|
||||||
const [effectiveDate, setEffectiveDate] = useState(todayIso());
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const vorhanden = Boolean(kontakt.name);
|
const vorhanden = Boolean(kontakt.name);
|
||||||
@@ -58,7 +57,7 @@ export function NotfallkontaktModal({
|
|||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await changeEmployeeData({
|
const result = await changeEmployeeData({
|
||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
effective_date: effectiveDate,
|
effective_date: todayIso(),
|
||||||
person: {
|
person: {
|
||||||
emergency_contact_name: name.trim(),
|
emergency_contact_name: name.trim(),
|
||||||
emergency_contact_phone: telefon.trim(),
|
emergency_contact_phone: telefon.trim(),
|
||||||
@@ -93,8 +92,11 @@ export function NotfallkontaktModal({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Kein „Wirksam ab": ein Notfallkontakt gilt ab sofort. Das Feld stand
|
||||||
|
hier, weil die Datenbankfunktion einen Stichtag kennt — bei einer
|
||||||
|
Telefonnummer, die im Ernstfall gewählt wird, ist ein Datum in der
|
||||||
|
Zukunft keine sinnvolle Angabe, sondern eine Frage zu viel. */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
|
||||||
<TextField label="Name" required value={name} onChange={setName} />
|
<TextField label="Name" required value={name} onChange={setName} />
|
||||||
<TextField label="Telefonnummer" required type="tel" value={telefon} onChange={setTelefon} />
|
<TextField label="Telefonnummer" required type="tel" value={telefon} onChange={setTelefon} />
|
||||||
{/* Dieselbe Liste wie im Einstellungsassistenten (EMERGENCY_RELATIONS)
|
{/* Dieselbe Liste wie im Einstellungsassistenten (EMERGENCY_RELATIONS)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import {
|
import {
|
||||||
BEGUENSTIGUNG_AB_GRAD,
|
|
||||||
GRUND_BEGUENSTIGT_BEHINDERT,
|
GRUND_BEGUENSTIGT_BEHINDERT,
|
||||||
KUENDIGUNGSSCHUTZ_GRUENDE,
|
KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||||
} from "@/lib/kuendigungsschutz";
|
} from "@/lib/kuendigungsschutz";
|
||||||
@@ -222,7 +221,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
|||||||
{value.hasKuendigungsschutz && (
|
{value.hasKuendigungsschutz && (
|
||||||
<>
|
<>
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Personenkreis"
|
label="Grund"
|
||||||
dense
|
dense
|
||||||
value={value.kuendigungsschutzGrund}
|
value={value.kuendigungsschutzGrund}
|
||||||
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
|
onChange={(v) => onChange({ kuendigungsschutzGrund: v })}
|
||||||
@@ -278,7 +277,6 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
|||||||
type="number"
|
type="number"
|
||||||
value={value.behinderungGrad}
|
value={value.behinderungGrad}
|
||||||
onChange={(v) => onChange({ behinderungGrad: v })}
|
onChange={(v) => onChange({ behinderungGrad: v })}
|
||||||
hint={`In Prozent laut Bescheid. Die Begünstigung beginnt bei ${BEGUENSTIGUNG_AB_GRAD}%.`}
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Bescheid ab"
|
label="Bescheid ab"
|
||||||
|
|||||||
@@ -319,23 +319,6 @@ export function DatenAendernPanel({
|
|||||||
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
|
|
||||||
er nicht zwischen den Adressfeldern der Person untergehen. */}
|
|
||||||
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
|
|
||||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
|
||||||
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
|
||||||
<SelectField
|
|
||||||
label="Verhältnis"
|
|
||||||
dense
|
|
||||||
value={notfallVerhaeltnis}
|
|
||||||
onChange={setNotfallVerhaeltnis}
|
|
||||||
placeholder="Bitte wählen…"
|
|
||||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -402,6 +385,26 @@ export function DatenAendernPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
||||||
|
|
||||||
|
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
|
||||||
|
wie im Reiter „Stammdaten". Vorher stand der Block mitten im
|
||||||
|
Abschnitt „Person", zwischen den Adressfeldern; beides sind
|
||||||
|
Personen im Umfeld, und die beiden gehören nebeneinander. */}
|
||||||
|
<fieldset className="rounded-md border border-border-subtle p-3">
|
||||||
|
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
||||||
|
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
||||||
|
<SelectField
|
||||||
|
label="Verhältnis"
|
||||||
|
dense
|
||||||
|
value={notfallVerhaeltnis}
|
||||||
|
onChange={setNotfallVerhaeltnis}
|
||||||
|
placeholder="Bitte wählen…"
|
||||||
|
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type Sicht = "alle" | "anstehend" | "erledigt";
|
|||||||
const SICHTEN: { value: Sicht; label: string }[] = [
|
const SICHTEN: { value: Sicht; label: string }[] = [
|
||||||
{ value: "alle", label: "Alle" },
|
{ value: "alle", label: "Alle" },
|
||||||
{ value: "anstehend", label: "Anstehend" },
|
{ value: "anstehend", label: "Anstehend" },
|
||||||
{ value: "erledigt", label: "Gelaufen" },
|
{ value: "erledigt", label: "Vergangen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
||||||
|
|||||||
@@ -643,11 +643,6 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
|||||||
{mode === "snapshot" && (
|
{mode === "snapshot" && (
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<div className="rounded border border-border bg-white p-4">
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
|
||||||
<p className="mb-1 text-xs text-ink-muted">
|
|
||||||
Teilnehmerliste für die Mitarbeiterbefragung: Personalnummer, Name, Standort und die
|
|
||||||
Organisationskette von der eigenen Einheit bis ganz oben. Sprache immer „de“, Rolle immer „Respondee“.
|
|
||||||
Die Spalte „Email“ bleibt leer, bis die Firmen-E-Mail-Adresse erfasst wird.
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
|
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
|
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
|
||||||
|
|||||||
129
db/migrations/20260923180000_austritt_anmerkung.sql
Normal file
129
db/migrations/20260923180000_austritt_anmerkung.sql
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
-- Die Anmerkung beim Austritt wird wieder aufgeschrieben.
|
||||||
|
--
|
||||||
|
-- Gefragt vom Kunden: „beim Austritt gibt es eine Anmerkung — wo sieht man
|
||||||
|
-- die nachher?" Die Antwort war: nirgends. Das Formular sammelt sie ein, die
|
||||||
|
-- Server Action reicht sie durch, und terminate_employee liess sie fallen.
|
||||||
|
--
|
||||||
|
-- Bis 20260814100000 stand sie in der Beschreibung des Ereignisses:
|
||||||
|
--
|
||||||
|
-- 'Austritt (' || exit_reason || ')'
|
||||||
|
-- || case when payload->>'note' <> '' then ' — ' || note else '' end
|
||||||
|
--
|
||||||
|
-- Beim Umschreiben der Funktion für den Nichtantritt ist der zweite Teil
|
||||||
|
-- verlorengegangen, und bei der Austrittsart (20260917100000) wurde die
|
||||||
|
-- bereits verkürzte Fassung weitergereicht. Genau davor warnt die
|
||||||
|
-- Selbstprüfung jener Migration im Kommentar — „der Fehler, den ein
|
||||||
|
-- `create or replace` aus einer alten Vorlage leise macht" —, und für die
|
||||||
|
-- Anmerkung ist er trotzdem passiert. Deshalb steht unten eine Prüfung, die
|
||||||
|
-- ihn beim nächsten Mal abfängt.
|
||||||
|
--
|
||||||
|
-- Die Anmerkung geht in **beide** Einträge: in die Personalakte, wo sie
|
||||||
|
-- hingehört, und ins Protokoll, wo ohne sie nicht nachvollziehbar wäre,
|
||||||
|
-- was jemand beim Austritt festgehalten hat. Angehängt an die fertige
|
||||||
|
-- Beschreibung und nicht in einen der Zweige: der Nichtantritt darf sie
|
||||||
|
-- genauso tragen.
|
||||||
|
--
|
||||||
|
-- Bestehende Einträge bleiben, wie sie sind. Der Text ist nicht mehr da; er
|
||||||
|
-- wurde nie geschrieben, und es gibt nichts, woraus er sich rekonstruieren
|
||||||
|
-- liesse.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.terminate_employee(payload jsonb)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $function$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_exit date := (payload->>'exit_date')::date;
|
||||||
|
v_name text;
|
||||||
|
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
|
||||||
|
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
|
||||||
|
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
|
||||||
|
v_entry date;
|
||||||
|
v_art text := nullif(payload->>'austrittsart', '');
|
||||||
|
v_note text := nullif(btrim(payload->>'note'), '');
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
select first_name || ' ' || last_name, entry_date into v_name, v_entry
|
||||||
|
from employees where id = v_employee_id;
|
||||||
|
|
||||||
|
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
|
||||||
|
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
|
||||||
|
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
|
||||||
|
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
|
||||||
|
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
|
||||||
|
if v_no_show then
|
||||||
|
v_exit := v_entry;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update employees set
|
||||||
|
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
|
||||||
|
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
|
||||||
|
-- denn es gibt keinen Lauf, der sie später nachzieht.
|
||||||
|
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
|
||||||
|
exit_date = v_exit,
|
||||||
|
exit_reason = payload->>'exit_reason',
|
||||||
|
austrittsart = v_art
|
||||||
|
where id = v_employee_id;
|
||||||
|
|
||||||
|
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
|
||||||
|
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
|
||||||
|
-- die nächste besetzte Ebene.
|
||||||
|
if v_no_show then
|
||||||
|
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
|
||||||
|
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
|
||||||
|
-- Intervall. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
|
||||||
|
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
|
||||||
|
delete from position_assignments
|
||||||
|
where employee_id = v_employee_id and valid_to is null;
|
||||||
|
else
|
||||||
|
update position_assignments set valid_to = v_exit
|
||||||
|
where employee_id = v_employee_id and valid_to is null;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description)
|
||||||
|
values (v_employee_id, v_exit, 'Austritt',
|
||||||
|
case when v_no_show
|
||||||
|
then 'Kein Antritt am ' || v_entry || ' (No Show)'
|
||||||
|
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')'
|
||||||
|
|| case when v_art is not null then ', ' || v_art else '' end end
|
||||||
|
|| case when v_note is not null then ' — ' || v_note else '' end);
|
||||||
|
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id,
|
||||||
|
case when v_no_show
|
||||||
|
then 'Kein Antritt am ' || v_entry || ' (No Show)'
|
||||||
|
else 'Austritt am ' || v_exit || case when v_art is not null then ', ' || v_art else '' end end
|
||||||
|
|| case when v_note is not null then ' — ' || v_note else '' end);
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
grant execute on function public.terminate_employee(payload jsonb) to alpenwerk_app;
|
||||||
|
|
||||||
|
|
||||||
|
-- Selbstprüfung.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_term text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
|
||||||
|
begin
|
||||||
|
if v_term not like '%v_note%' then
|
||||||
|
raise exception 'terminate_employee schreibt die Anmerkung nicht — das Feld im Formular ginge wieder ins Leere.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Was diese Funktion schon einmal verloren hat, darf sie nicht wieder
|
||||||
|
-- verlieren. Dieselben Prüfungen wie in 20260917100000, hier um die
|
||||||
|
-- Anmerkung ergänzt.
|
||||||
|
if v_term not like '%austrittsart%' then
|
||||||
|
raise exception 'terminate_employee schreibt die Austrittsart nicht mehr';
|
||||||
|
end if;
|
||||||
|
if v_term not like '%v_no_show%' or v_term not like '%delete from position_assignments%' then
|
||||||
|
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr';
|
||||||
|
end if;
|
||||||
|
if position('SET search_path' in v_term) = 0 then
|
||||||
|
raise exception 'terminate_employee hat keinen festen search_path mehr';
|
||||||
|
end if;
|
||||||
|
if v_term like '%auth.uid()%' then
|
||||||
|
raise exception 'terminate_employee ruft wieder auth.uid()';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
39
lib/historie-felder.ts
Normal file
39
lib/historie-felder.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ABSENCE_TYPES } from "./absence";
|
||||||
|
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
|
||||||
|
import { MITARBEITERARTEN } from "./mitarbeiterart";
|
||||||
|
import { EMERGENCY_RELATIONS } from "./types";
|
||||||
|
|
||||||
|
// Welche Felder in „Eintrag berichtigen" eine Auswahl statt eines Textfeldes
|
||||||
|
// bekommen.
|
||||||
|
//
|
||||||
|
// Der Dialog zeigte für jedes Feld dasselbe freie Textfeld — auch für
|
||||||
|
// „Notfallkontakt Verhältnis", wo die Erfassung überall sonst eine Liste
|
||||||
|
// führt. Wer dort „Gattin" statt „Gattin/Gatte" eintippte, erzeugte einen
|
||||||
|
// Wert, den keine Auswertung mehr findet, und merkte nichts davon: die
|
||||||
|
// Berichtigung schreibt in dieselbe Spalte wie das Formular, nur ohne dessen
|
||||||
|
// Prüfung.
|
||||||
|
//
|
||||||
|
// Die Schlüssel sind die Bezeichnungen aus `app_feld_karte()` — genau die
|
||||||
|
// Zeichenketten, die in `employee_history.changes` unter `feld` stehen. Ein
|
||||||
|
// Tippfehler hier fiele nicht auf, das Feld bliebe einfach ein Textfeld;
|
||||||
|
// deshalb prüft tests/unit/historie-felder.test.ts jeden Schlüssel gegen die
|
||||||
|
// Migration.
|
||||||
|
//
|
||||||
|
// Bewusst **nicht** aufgenommen sind die Felder mit Aufzählungstypen in der
|
||||||
|
// Datenbank (Geschlecht, Beschäftigungsausmaß, Vertragsart, Besetzungsart …)
|
||||||
|
// und die Ja/Nein-Felder. Dort ist der gespeicherte Text nicht die Anzeige —
|
||||||
|
// `m`/`w`, `true`/`false` —, und eine Liste, die die Anzeigeform schreibt,
|
||||||
|
// liefe in einen Umwandlungsfehler oder, schlimmer, in einen stillen
|
||||||
|
// Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht
|
||||||
|
// nebenbei hier.
|
||||||
|
export const FELD_AUSWAHLEN: Record<string, readonly string[]> = {
|
||||||
|
"Notfallkontakt Verhältnis": EMERGENCY_RELATIONS,
|
||||||
|
"Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||||
|
Mitarbeiterart: MITARBEITERARTEN,
|
||||||
|
"Art der Abwesenheit": ABSENCE_TYPES,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Die Auswahl zu einem Feld, oder nichts — dann bleibt es ein Textfeld. */
|
||||||
|
export function auswahlFuer(feld: string): readonly string[] | undefined {
|
||||||
|
return FELD_AUSWAHLEN[feld];
|
||||||
|
}
|
||||||
@@ -263,10 +263,10 @@ describe("HistorieTab — Filter", () => {
|
|||||||
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
|
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blendet mit „Gelaufen“ das Anstehende aus", async () => {
|
it("blendet mit „Vergangen“ das Anstehende aus", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
||||||
await user.click(screen.getByRole("button", { name: "Gelaufen" }));
|
await user.click(screen.getByRole("button", { name: "Vergangen" }));
|
||||||
expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument();
|
expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
|
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
52
tests/unit/historie-felder.test.ts
Normal file
52
tests/unit/historie-felder.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
|
||||||
|
|
||||||
|
// Die Schlüssel sind Bezeichnungen aus `app_feld_karte()`. Stimmt eine nicht
|
||||||
|
// mehr — umbenannt, vertippt —, fällt das nirgends auf: das Feld bliebe im
|
||||||
|
// Dialog „Eintrag berichtigen" einfach ein Textfeld, und getippte Werte
|
||||||
|
// landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den
|
||||||
|
// diese Datei beheben soll.
|
||||||
|
//
|
||||||
|
// Geprüft wird gegen die zuletzt gültige Fassung der Feldkarte.
|
||||||
|
const MIGRATION = join(process.cwd(), "db/migrations/20260923120000_firmenemail.sql");
|
||||||
|
|
||||||
|
function bezeichnungenDerFeldkarte(): string[] {
|
||||||
|
const sql = readFileSync(MIGRATION, "utf8");
|
||||||
|
const block = sql.match(/FUNCTION public\.app_feld_karte\(\)[\s\S]*?\$function\$;/)?.[0];
|
||||||
|
expect(block, "app_feld_karte steht nicht mehr in dieser Migration").toBeDefined();
|
||||||
|
// Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'),
|
||||||
|
return [...block!.matchAll(/^\s*'([^']+)',\s+jsonb_build_array\(/gm)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => {
|
||||||
|
it("nennen nur Felder, die es in der Feldkarte gibt", () => {
|
||||||
|
const bekannt = new Set(bezeichnungenDerFeldkarte());
|
||||||
|
expect(bekannt.size).toBeGreaterThan(30);
|
||||||
|
for (const feld of Object.keys(FELD_AUSWAHLEN)) {
|
||||||
|
expect(bekannt, `„${feld}“ steht nicht in app_feld_karte()`).toContain(feld);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bieten für das Verhältnis des Notfallkontakts eine Liste an", () => {
|
||||||
|
// Der gemeldete Fall: dort liess sich freier Text eintragen, während die
|
||||||
|
// Erfassung überall sonst eine Liste führt.
|
||||||
|
expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Gattin/Gatte");
|
||||||
|
expect(auswahlFuer("Notfallkontakt Verhältnis")).toContain("Tochter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lassen Felder ohne feste Liste als Textfeld stehen", () => {
|
||||||
|
expect(auswahlFuer("Adresse")).toBeUndefined();
|
||||||
|
expect(auswahlFuer("Wochenstunden")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führen keine Felder mit Aufzählungstyp", () => {
|
||||||
|
// Dort ist der gespeicherte Wert nicht die Anzeige (`m`/`w`,
|
||||||
|
// `true`/`false`). Eine Liste, die die Anzeigeform schreibt, liefe in
|
||||||
|
// einen Umwandlungsfehler oder in einen stillen Fehlwert.
|
||||||
|
for (const feld of ["Geschlecht", "Beschäftigungsausmaß", "Vertragsart", "Besetzungsart", "Betriebsrat"]) {
|
||||||
|
expect(auswahlFuer(feld), `„${feld}“ gehört nicht in die Auswahl`).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user