Data model - employee_assignments records org placement over time (valid_from/valid_to), written by a trigger on `employees` rather than inside each RPC: ~70 `update employees` statements spread over fifteen migrations mean per-call bookkeeping would miss paths today and again with every future RPC. A partial unique index enforces the one-open-interval invariant the trigger relies on when closing the current row. - The Organigramm gains a Stichtag (default today). Membership comes from entry/exit/karenz, past placement from the new history, future placement projected from pending_org_changes. Placements predating the migration are backfilled with today's values and flagged as such in the UI, since employee_history only ever stored free text and cannot be reconstructed. Correctness - Reports and exports silently truncated at PostgREST's 1000-row cap (db.max_rows); employee_history is already past it at ~800 staff. Every whole-table read now pages explicitly. - XLSX date cells were a day early: ExcelJS converts a Date to an Excel serial straight off getTime(), so a Date built at local midnight lands on the previous day's serial in any positive-offset zone. - Date handling is pinned to Europe/Vienna throughout, and date-only strings are formatted without a Date round-trip. The dashboard's YTD window was built by round-tripping a local Date through toISOString(), which shifted it a day early and dropped 31 December entirely. - Export routes parsed measure/group/split/eventType with unchecked `as` casts, so an unknown value reached column headers as `undefined` and the Content-Disposition filename. Parsed against the label maps now, with the filename slugged as a backstop. - toXlsx keyed columns by header text, silently dropping the second of any two columns sharing a name — split columns take their header from data. - The org chart tree walks had no cycle guard; nothing in the schema forbids a manager_id cycle, and one would hang the tab rather than misreport. - The login page reflected ?error= verbatim, letting anyone put arbitrary text on the real sign-in screen; messages are looked up by code now. - React Flow needs elementsSelectable on, or it sets pointer-events:none on the whole node and the expand control stops responding. UI - Mobile: the shell was unusable below lg — a fixed 236px margin pushed content off-screen with no mobile navigation at all. The sidebar is now a drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so iOS stops zooming on focus, and form grids stack. - Org chart nodes redesigned: per-kind accent stripes and icons, vacant roles called out, expand control moved to the bottom edge carrying the child count. - Pagination is windowed; it previously rendered one link per page (54 for the employee list, unbounded for the audit log). - Positions page reduced to open positions with a single "Besetzen" action. - The employee Organisation tab links into the org chart focused on that person, reusing the chart's existing search-match highlighting. Also included, uncommitted until now - Dependants, HR notes, academic titles, split address fields, position validity and role/employment fields, with their migrations and UI. - Docker/compose deployment setup, data-model and security-review docs.
100 lines
4.5 KiB
TypeScript
100 lines
4.5 KiB
TypeScript
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||
import type { PaygradeType } from "@/lib/supabase/types";
|
||
import type { HireDraftData } from "./types";
|
||
|
||
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
|
||
{ value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
|
||
{ value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
|
||
{ value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
|
||
{ value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" },
|
||
{ value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
|
||
{ value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
|
||
];
|
||
|
||
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
|
||
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
|
||
update({
|
||
employmentType: value,
|
||
weeklyHours: value === "Vollzeit" ? "38.5" : draft.weeklyHours === "38.5" ? "20" : draft.weeklyHours,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Eintrittsdatum*</label>
|
||
<input type="date" value={draft.entryDate} onChange={(e) => update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Vertragsart</label>
|
||
<select
|
||
value={draft.contractType}
|
||
onChange={(e) => update({ contractType: e.target.value as HireDraftData["contractType"] })}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
>
|
||
<option value="unbefristet">unbefristet</option>
|
||
<option value="befristet">befristet</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{draft.contractType === "befristet" && (
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Befristet bis*</label>
|
||
<input
|
||
type="date"
|
||
value={draft.contractEndDate}
|
||
onChange={(e) => update({ contractEndDate: e.target.value })}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label>
|
||
<select
|
||
value={draft.employmentType}
|
||
onChange={(e) => handleEmploymentTypeChange(e.target.value as HireDraftData["employmentType"])}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
>
|
||
<option value="Vollzeit">Vollzeit</option>
|
||
<option value="Teilzeit">Teilzeit</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden</label>
|
||
<input
|
||
type="number"
|
||
step="0.5"
|
||
value={draft.weeklyHours}
|
||
disabled={draft.employmentType === "Vollzeit"}
|
||
onChange={(e) => update({ weeklyHours: e.target.value })}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade*</label>
|
||
<select
|
||
value={draft.paygrade}
|
||
onChange={(e) => update({ paygrade: e.target.value as PaygradeType })}
|
||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||
>
|
||
{PAYGRADES.map((p) => (
|
||
<option key={p.value} value={p.value}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<p className="mt-1 text-xs text-ink-muted">{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}</p>
|
||
</div>
|
||
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
|
||
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-bold text-ink">Rolle & Anstellung</h3>
|
||
<RoleEmploymentFields value={draft} onChange={update} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|