Org assignment history, mobile support, and a correctness pass

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.
This commit is contained in:
2026-07-24 23:38:10 +02:00
parent f96773da0f
commit 79f0e19bf8
101 changed files with 6120 additions and 700 deletions

View File

@@ -0,0 +1,144 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { addEmployeeDependent } from "@/actions/employees";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { RelationshipType } from "@/lib/supabase/types";
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
export function AddDependentModal({
open,
onClose,
employeeId,
defaultEffectiveDate,
}: {
open: boolean;
onClose: () => void;
employeeId: string;
defaultEffectiveDate?: string;
}) {
const { showToast } = useToast();
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [svNummer, setSvNummer] = useState("");
const [birthDate, setBirthDate] = useState("");
const [relationship, setRelationship] = useState<RelationshipType>("Kind");
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
const [pending, setPending] = useState(false);
// This modal stays mounted while closed, so a mount-time initializer would
// pin the date forever: DatenAendernPanel drives `defaultEffectiveDate` off
// its own "Wirksam ab" field, and changing it there has to reach the field
// below. Same adjust-during-render pattern as CountryPicker.
if (defaultEffectiveDate !== prevDefaultEffectiveDate) {
setPrevDefaultEffectiveDate(defaultEffectiveDate);
if (defaultEffectiveDate) setEffectiveDate(defaultEffectiveDate);
}
function reset() {
setFirstName("");
setLastName("");
setSvNummer("");
setBirthDate("");
setRelationship("Kind");
setEffectiveDate(defaultEffectiveDate ?? todayIso());
}
async function handleSubmit() {
if (!firstName || !lastName || !birthDate || !effectiveDate) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
setPending(true);
const result = await addEmployeeDependent({
employee_id: employeeId,
first_name: firstName,
last_name: lastName,
relationship,
sv_nummer: svNummer || undefined,
birth_date: birthDate,
effective_date: effectiveDate,
});
setPending(false);
if (result.success) {
showToast("Angehörige:r hinzugefügt.");
router.refresh();
onClose();
reset();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Angehörige:n hinzufügen"
footer={
<>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
Abbrechen
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Hinzufügen
</button>
</>
}
>
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
<input
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(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">Vorname*</label>
<input value={firstName} onChange={(e) => setFirstName(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">Nachname*</label>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">SVNR</label>
<input value={svNummer} onChange={(e) => setSvNummer(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">Geburtsdatum*</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(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">Verwandtschaftsverhältnis*</label>
<select
value={relationship}
onChange={(e) => setRelationship(e.target.value as RelationshipType)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{RELATIONSHIPS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deleteEmployeeDependent } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import { AddDependentModal } from "./AddDependentModal";
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
// `effectiveDate` lets DatenAendernPanel embed this section and drive
// add/remove off its own "Wirksam ab" field; standalone usage (Stammdaten
// tab) omits it and defaults to today, i.e. immediate.
export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: { employeeId: string; dependents: Dependent[]; effectiveDate?: string }) {
const { showToast } = useToast();
const router = useRouter();
const [modalOpen, setModalOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const resolvedEffectiveDate = effectiveDate || todayIso();
async function handleDelete(dependentId: string) {
setDeletingId(dependentId);
const result = await deleteEmployeeDependent({ dependent_id: dependentId, employee_id: employeeId, effective_date: resolvedEffectiveDate });
setDeletingId(null);
if (result.success) {
showToast("Angehörige:r entfernt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
}
return (
<div className="border-t border-border pt-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Angehörige</h3>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalOpen(true)}
className="flex items-center gap-1 text-xs font-semibold text-brand-700 hover:text-brand-600"
>
<Plus className="h-3.5 w-3.5" /> Hinzufügen
</button>
<span className="text-xs text-ink-muted">{dependents.length} Personen</span>
</div>
</div>
{dependents.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Angehörigen hinterlegt.</p>
) : (
<div className="overflow-x-auto rounded border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Verhältnis</th>
<th className="px-4 py-2.5">SVNR</th>
<th className="px-4 py-2.5">Geburtsdatum</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{dependents.map((d) => (
<tr key={d.id} className="border-b border-border last:border-0">
<td className="px-4 py-2.5 font-semibold text-ink">
{d.first_name} {d.last_name}
</td>
<td className="px-4 py-2.5 text-ink-body">{d.relationship}</td>
<td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? ""}</td>
<td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={deletingId === d.id}
aria-label="Angehörige:n entfernen"
className="text-ink-muted hover:text-danger-solid disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AddDependentModal open={modalOpen} onClose={() => setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} />
</div>
);
}

View File

@@ -5,7 +5,7 @@ import Link from "next/link";
import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { StatusChip } from "@/components/ui/StatusChip";
import { tenure } from "@/lib/format";
import { fmtFullName, tenure } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel";
@@ -14,6 +14,7 @@ import { RehirePanel } from "./panels/RehirePanel";
import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
@@ -24,6 +25,8 @@ type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
@@ -32,6 +35,8 @@ type EmployeeDetailProps = {
manager: MiniEmployee | null;
directReports: MiniEmployee[];
history: HistoryRow[];
dependents: Dependent[];
notes: NoteRow[];
divisions: Division[];
departments: Department[];
teams: Team[];
@@ -40,10 +45,10 @@ type EmployeeDetailProps = {
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, manager, directReports, history, divisions, departments, teams, locations } = props;
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
@@ -54,6 +59,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
const location = locations.find((l) => l.id === employee.location_id);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten";
return (
<div className="flex flex-col gap-6">
@@ -68,7 +74,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-extrabold text-ink">
{employee.first_name} {employee.last_name}
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
</h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} />
</div>
@@ -87,15 +93,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
<button
onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
</button>
</>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && (
<button
onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
</button>
)}
{employee.status === "Ausgetreten" && (
<button
onClick={() => setPanel("rehire")}
@@ -117,16 +125,19 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t}
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
</button>
))}
</div>
<div className="rounded border border-border bg-white p-6">
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
{tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
{tab === "Organisation" && (
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
)}
{tab === "Historie" && <HistorieTab history={history} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
<TransferPanel
@@ -140,7 +151,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
/>
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} dependents={dependents} />
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</div>

View File

@@ -0,0 +1,89 @@
"use client";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
export type RoleEmploymentValue = {
workerType: WorkerType;
collectiveAgreement: CollectiveAgreement;
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
isLateraleFuehrung: boolean;
isCLevel: boolean;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
// the same set of employees columns, just against different local state.
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
function toggleWorkDay(day: Weekday) {
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] });
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Angestellte:r / Arbeiter:in</label>
<select
value={value.workerType}
onChange={(e) => onChange({ workerType: e.target.value as WorkerType })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Angestellte:r">Angestellte:r</option>
<option value="Arbeiter:in">Arbeiter:in</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Kollektivvertrag</label>
<select
value={value.collectiveAgreement}
onChange={(e) => onChange({ collectiveAgreement: e.target.value as CollectiveAgreement })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Handel">Handel</option>
<option value="Süßwaren">Süßwaren</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWorkDay(day)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold ${
value.workDays.includes(day) ? "bg-brand-500 text-white" : "border border-border text-ink-muted hover:bg-surface"
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isBetriebsrat} onChange={(e) => onChange({ isBetriebsrat: e.target.checked })} />
Betriebsrat
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
Dienstwagen
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
Laterale Führung
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
C-Level
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { Picklist } from "@/components/ui/Picklist";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
export type TitleValue = { titlePrefix: string[]; titleSuffix: string[] };
// Shared by the hire wizard (StepPerson) and DatenAendernPanel — both edit
// the same title_prefix/title_suffix columns, just against different local
// state, same pattern as RoleEmploymentFields.
export function TitleFields({ value, onChange }: { value: TitleValue; onChange: (patch: Partial<TitleValue>) => void }) {
return (
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (vorangestellt)</label>
<Picklist options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (nachgestellt)</label>
<Picklist options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />
</div>
</div>
);
}

View File

@@ -3,27 +3,48 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees";
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { CountryPicker } from "@/components/ui/CountryPicker";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries";
import { fmtFullName, todayIso } from "@/lib/format";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
export function DatenAendernPanel({
open,
onClose,
employee,
dependents,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
dependents: Dependent[];
}) {
const { showToast } = useToast();
const router = useRouter();
const [pending, setPending] = useState(false);
const [effectiveDate, setEffectiveDate] = useState(new Date().toISOString().slice(0, 10));
const [effectiveDate, setEffectiveDate] = useState(todayIso);
const [firstName, setFirstName] = useState(employee.first_name);
const [lastName, setLastName] = useState(employee.last_name);
const [titles, setTitles] = useState<TitleValue>({ titlePrefix: employee.title_prefix ?? [], titleSuffix: employee.title_suffix ?? [] });
function updateTitles(patch: Partial<TitleValue>) {
setTitles((prev) => ({ ...prev, ...patch }));
}
const [gender, setGender] = useState<GenderType>(employee.gender);
const [birthDate, setBirthDate] = useState(employee.birth_date);
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
const [nationality, setNationality] = useState(employee.nationality);
const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email);
const [phone, setPhone] = useState(employee.phone ?? "");
@@ -33,6 +54,19 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
const [role, setRole] = useState<RoleEmploymentValue>({
workerType: employee.worker_type ?? "Angestellte:r",
collectiveAgreement: employee.collective_agreement ?? "Handel",
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
});
function updateRole(patch: Partial<RoleEmploymentValue>) {
setRole((prev) => ({ ...prev, ...patch }));
}
function handleEmploymentTypeChange(value: EmploymentType) {
setEmploymentType(value);
if (value === "Vollzeit") setWeeklyHours("38.5");
@@ -47,6 +81,10 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
showToast("Bei befristetem Vertrag ist ein Enddatum erforderlich.", "error");
return;
}
if (role.workDays.length === 0) {
showToast("Mindestens ein Arbeitstag muss ausgewählt sein.", "error");
return;
}
if (!effectiveDate) {
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return;
@@ -58,11 +96,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
person: {
first_name: firstName,
last_name: lastName,
title_prefix: titles.titlePrefix,
title_suffix: titles.titleSuffix,
gender,
birth_date: birthDate,
sv_nummer: svNummer,
nationality,
address,
postal_code: postalCode,
city,
address_country: addressCountry,
email,
phone,
@@ -73,6 +115,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
contract_type: contractType,
contract_end_date: contractType === "befristet" ? contractEndDate : "",
},
role: {
worker_type: role.workerType,
collective_agreement: role.collectiveAgreement,
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},
});
setPending(false);
if (result.success) {
@@ -89,7 +140,7 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
open={open}
onClose={onClose}
title="Daten ändern"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
subtitle={`${fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} · ${employee.job_title}`}
footer={
<>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
@@ -119,7 +170,7 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -129,7 +180,8 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<TitleFields value={titles} onChange={updateTitles} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label>
<select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm">
@@ -155,16 +207,24 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse (Straße und Hausnummer)</label>
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse</label>
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
<label className="mb-1 block text-xs font-semibold text-ink-muted">Postleitzahl</label>
<input value={postalCode} onChange={(e) => setPostalCode(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label>
<CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />
<label className="mb-1 block text-xs font-semibold text-ink-muted">Ort</label>
<input value={city} onChange={(e) => setCity(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label>
<CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">E-Mail</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -225,6 +285,13 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
)}
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Rolle & Anstellung</h3>
<RoleEmploymentFields value={role} onChange={updateRole} />
</div>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
</div>
</SlideOver>
);

View File

@@ -1,11 +1,11 @@
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
export function HistorieTab({ history }: { history: HistoryRow[] }) {
const today = new Date().toISOString().slice(0, 10);
const today = todayIso();
if (history.length === 0) {
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;

View File

@@ -0,0 +1,134 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { addEmployeeNote, completeEmployeeNote } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { Database, NoteCategory } from "@/lib/supabase/types";
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
const CATEGORIES: NoteCategory[] = ["Allgemein", "Vertraulich", "Personalgespräch", "Wiedervorlage", "Lob / Anerkennung"];
export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: Note[] }) {
const { showToast } = useToast();
const router = useRouter();
const [category, setCategory] = useState<NoteCategory>("Allgemein");
const [noteText, setNoteText] = useState("");
const [dueDate, setDueDate] = useState("");
const [pending, setPending] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
async function handleSubmit() {
if (!noteText.trim()) {
showToast("Bitte einen Notiztext eingeben.", "error");
return;
}
setPending(true);
const result = await addEmployeeNote({
employee_id: employeeId,
category,
note_text: noteText.trim(),
due_date: dueDate || undefined,
});
setPending(false);
if (result.success) {
showToast("Notiz hinzugefügt.");
setNoteText("");
setDueDate("");
setCategory("Allgemein");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
async function handleComplete(noteId: string) {
setCompletingId(noteId);
const result = await completeEmployeeNote({ note_id: noteId, employee_id: employeeId });
setCompletingId(null);
if (result.success) {
showToast("Notiz erledigt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-start gap-2 rounded bg-info-bg px-4 py-3 text-sm text-info-text">
🔒 Interne HR-Notizen nur für die Personalabteilung sichtbar. Jede Notiz wird mit Verfasser:in und Datum protokolliert.
</div>
<div className="flex flex-col gap-3 rounded border border-border p-4">
<h3 className="text-sm font-bold text-ink">Neue Notiz erfassen</h3>
<div>
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
placeholder="Notiz zum/zur Mitarbeiter:in … (z. B. Gesprächsinhalt, Vereinbarung, Beobachtung)"
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">Kategorie</label>
<select value={category} onChange={(e) => setCategory(e.target.value as NoteCategory)} className="w-full rounded border border-border px-3 py-2 text-sm">
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wiedervorlage am (optional)</label>
<input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div className="flex justify-end">
<button onClick={handleSubmit} disabled={pending} className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">
Notiz speichern
</button>
</div>
</div>
{notes.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Notizen vorhanden.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{notes.map((n) => (
<li key={n.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
<span className="text-sm font-semibold text-ink">{n.author_name}</span>
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
{n.done ? (
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
) : (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{n.note_text}</p>
{n.due_date && !n.done && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
{!n.done && (
<button
type="button"
onClick={() => handleComplete(n.id)}
disabled={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
>
Erledigt
</button>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,20 +1,34 @@
import { Network } from "lucide-react";
import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OrganisationTabProps = {
employeeId: string;
manager: MiniEmployee | null;
directReports: MiniEmployee[];
breadcrumb: string;
};
export function OrganisationTab({ manager, directReports, breadcrumb }: OrganisationTabProps) {
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
return (
<div className="flex flex-col gap-6">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
</div>
{/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on
arrival instead of the user hunting for them. */}
<Link
href={`/orgchart?focus=${employeeId}`}
className="flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
<Network className="h-4 w-4" />
Im Organigramm anzeigen
</Link>
</div>
<div>

View File

@@ -1,28 +1,44 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) {
function formatAddress(employee: EmployeeRow): string {
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
return [employee.address, cityLine].filter(Boolean).join(", ") || "";
}
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
// Defensive against a DB that hasn't received the title_prefix/title_suffix
// migration yet — select("*") simply omits unknown columns, so these can
// be undefined rather than the empty array the column default implies.
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
const rows: [string, string][] = [
["Titel", titles.length > 0 ? titles.join(", ") : ""],
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
["SV-Nummer", employee.sv_nummer ?? ""],
["Staatsbürgerschaft", employee.nationality],
["E-Mail", employee.email],
["Telefon", employee.phone ?? ""],
["Standort", location ? `${location.name} (${location.country})` : ""],
["Adresse", employee.address ?? ""],
["Adresse", formatAddress(employee)],
["Land", employee.address_country ?? ""],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
];
return (
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
<dd className="mt-1 text-sm text-ink">{value}</dd>
</div>
))}
</dl>
<div className="flex flex-col gap-6">
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
<dd className="mt-1 text-sm text-ink">{value}</dd>
</div>
))}
</dl>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
</div>
);
}

View File

@@ -13,14 +13,23 @@ const PAYGRADE_LABELS: Record<string, string> = {
};
export function VertragTab({ employee }: { employee: EmployeeRow }) {
const flags = [
employee.is_betriebsrat && "Betriebsrat",
employee.has_dienstwagen && "Dienstwagen",
employee.is_laterale_fuehrung && "Laterale Führung",
employee.is_c_level && "C-Level",
].filter(Boolean);
const rows: [string, string][] = [
["Eintrittsdatum", fmtDate(employee.entry_date)],
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
["Kollektivvertrag", "Metalltechnische Industrie"],
["Kollektivvertrag", employee.collective_agreement ?? ""],
["Beschäftigungsausmaß", employee.employment_type],
["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Angestellte:r / Arbeiter:in", employee.worker_type ?? ""],
["Arbeitstage", employee.work_days?.join(", ") || ""],
["Merkmale", flags.length > 0 ? flags.join(", ") : ""],
];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -51,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
const stepValid = [
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate),
Boolean(draft.entryDate && draft.workDays.length > 0),
true,
][step];
@@ -72,6 +72,8 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
const result = await hireEmployee({
first_name: draft.firstName,
last_name: draft.lastName,
title_prefix: draft.titlePrefix,
title_suffix: draft.titleSuffix,
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
@@ -85,6 +87,13 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
weekly_hours: Number(draft.weeklyHours),
paygrade: draft.paygrade,
source: draft.besetzung,
worker_type: draft.workerType,
collective_agreement: draft.collectiveAgreement,
work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen,
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
});
setSubmitting(false);
if (result.success) {

View File

@@ -1,3 +1,4 @@
import { TitleFields } from "@/components/employees/TitleFields";
import type { HireDraftData } from "./types";
type StepPersonProps = {
@@ -9,7 +10,7 @@ type StepPersonProps = {
export function StepPerson({ draft, update, locations }: StepPersonProps) {
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label>
<input value={draft.firstName} onChange={(e) => update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -19,7 +20,8 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<input value={draft.lastName} onChange={(e) => update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<TitleFields value={draft} onChange={update} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label>
<select
@@ -40,7 +42,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label>
<input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>
<input type="email" value={draft.email} onChange={(e) => update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />

View File

@@ -1,5 +1,6 @@
import { X } from "lucide-react";
import { Lookup } from "@/components/ui/Lookup";
import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
@@ -42,6 +43,7 @@ export function StepPosition({ draft, update, openPositions }: StepPositionProps
<div className="text-xs text-ink-muted">
{selected.position_number} · {selected.orgLabel}
</div>
<div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div>
</div>
<button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
<X className="h-4 w-4 text-ink-muted" />

View File

@@ -1,4 +1,4 @@
import { fmtDate } from "@/lib/format";
import { fmtDate, fmtFullName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
@@ -19,8 +19,14 @@ type StepSummaryProps = {
export function StepSummary({ draft, selectedPosition, locations }: StepSummaryProps) {
const location = locations.find((l) => l.id === draft.locationId);
const flags = [
draft.isBetriebsrat && "Betriebsrat",
draft.hasDienstwagen && "Dienstwagen",
draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level",
].filter(Boolean);
const rows: [string, string][] = [
["Name", `${draft.firstName} ${draft.lastName}`],
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || ""],
@@ -35,11 +41,15 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
["Angestellte:r / Arbeiter:in", draft.workerType],
["Kollektivvertrag", draft.collectiveAgreement],
["Arbeitstage", draft.workDays.join(", ") || ""],
["Merkmale", flags.length > 0 ? flags.join(", ") : ""],
];
return (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{rows.map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>

View File

@@ -1,3 +1,4 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import type { PaygradeType } from "@/lib/supabase/types";
import type { HireDraftData } from "./types";
@@ -20,7 +21,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<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" />
@@ -48,7 +49,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<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
@@ -88,6 +89,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
<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>
);
}

View File

@@ -1,4 +1,4 @@
import type { ContractType, EmploymentType, GenderType, PaygradeType } from "@/lib/supabase/types";
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them
@@ -6,6 +6,8 @@ import type { ContractType, EmploymentType, GenderType, PaygradeType } from "@/l
export type HireDraftData = {
firstName: string;
lastName: string;
titlePrefix: string[];
titleSuffix: string[];
gender: GenderType;
birthDate: string;
svNummer: string;
@@ -20,11 +22,20 @@ export type HireDraftData = {
employmentType: EmploymentType;
weeklyHours: string;
paygrade: PaygradeType;
workerType: WorkerType;
collectiveAgreement: CollectiveAgreement;
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
isLateraleFuehrung: boolean;
isCLevel: boolean;
};
export const EMPTY_HIRE_DRAFT: HireDraftData = {
firstName: "",
lastName: "",
titlePrefix: [],
titleSuffix: [],
gender: "m",
birthDate: "",
svNummer: "",
@@ -39,4 +50,11 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
employmentType: "Vollzeit",
weeklyHours: "38.5",
paygrade: "B",
workerType: "Angestellte:r",
collectiveAgreement: "Handel",
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: false,
hasDienstwagen: false,
isLateraleFuehrung: false,
isCLevel: false,
};

View File

@@ -0,0 +1,76 @@
"use client";
import { CalendarClock } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { fmtDate } from "@/lib/format";
type AsOfPickerProps = {
asOf: string;
today: string;
/** Placements projected from effective-dated changes not yet applied. */
projectedCount: number;
/** Earliest date the assignment history covers; before that it is today's placement. */
historyStartsAt: string | null;
};
export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsOfPickerProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setAsOf(value: string | undefined) {
const sp = new URLSearchParams(searchParams.toString());
if (value && value !== today) sp.set("asOf", value);
else sp.delete("asOf");
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
}
const isToday = asOf === today;
const isFuture = asOf > today;
// Assignments only started being recorded when the history table was
// introduced; asking for a date before that yields today's placement for
// everyone, which is worth saying out loud rather than quietly implying.
const beforeHistory = historyStartsAt !== null && asOf < historyStartsAt;
return (
<div className="rounded border border-border bg-white p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<label htmlFor="orgchart-asof" className="flex items-center gap-1.5 text-sm font-semibold text-ink">
<CalendarClock className="h-4 w-4 text-ink-muted" />
Stichtag
</label>
<input
id="orgchart-asof"
type="date"
value={asOf}
onChange={(e) => setAsOf(e.target.value || undefined)}
className="rounded border border-border px-3 py-2 text-sm"
/>
{!isToday && (
<button type="button" onClick={() => setAsOf(undefined)} className="text-xs font-semibold text-brand-700 hover:underline">
Heute
</button>
)}
<span className="text-xs text-ink-muted">
{isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`}
</span>
</div>
{isFuture && (
<p className="mt-2 rounded bg-info-bg px-3 py-2 text-xs text-info-text">
Vorschau: {projectedCount > 0
? `${projectedCount} geplante Versetzung(en)/Reorganisation(en) sind eingerechnet.`
: "Für diesen Zeitraum sind keine Versetzungen vorgemerkt."}{" "}
Ein-/Austritte und Karenzen sind berücksichtigt.
</p>
)}
{beforeHistory && (
<p className="mt-2 rounded bg-warning-bg px-3 py-2 text-xs text-warning-text">
Vor dem {fmtDate(historyStartsAt)} wurde die Zuordnungshistorie noch nicht aufgezeichnet. Wer beschäftigt war,
stimmt; Team und Vorgesetzte zeigen für diesen Stichtag die heutige Zuordnung.
</p>
)}
</div>
);
}

View File

@@ -2,13 +2,21 @@
import { ChevronDown, ChevronRight, Search } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import type { OrgEmployee } from "./types";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee } from "./types";
export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
type ViewMode = "list" | "graph";
export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmployee[]; focusId?: string | null }) {
// Seeded with the focus target so their own reports are already unfolded;
// the chain *above* them comes from ancestorExpandIds.
const [expanded, setExpanded] = useState<Set<string>>(() => (focusId ? new Set([focusId]) : new Set()));
const [query, setQuery] = useState("");
const [mode, setMode] = useState<ViewMode>("list");
const focusRef = useRef<HTMLDivElement>(null);
const { childrenByManager, totalReportsById, root } = useMemo(() => {
const byManager = new Map<string, OrgEmployee[]>();
@@ -19,22 +27,33 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
}
for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name));
// Nothing in the schema forbids a manager_id cycle (A reports to B
// reports to A), and a reorg that moves a lead under one of their own
// reports would create one. Every walk below is recursive, so an
// unguarded cycle is an infinite recursion that hangs the tab rather
// than a wrong number — hence the on-path set.
const totals = new Map<string, number>();
function countTotal(id: string): number {
if (totals.has(id)) return totals.get(id)!;
const direct = byManager.get(id) ?? [];
let total = direct.length;
for (const child of direct) total += countTotal(child.id);
function countTotal(id: string, path: Set<string>): number {
const memo = totals.get(id);
if (memo !== undefined) return memo;
if (path.has(id)) return 0;
path.add(id);
let total = 0;
for (const child of byManager.get(id) ?? []) total += 1 + countTotal(child.id, path);
path.delete(id);
totals.set(id, total);
return total;
}
for (const e of employees) countTotal(e.id);
for (const e of employees) countTotal(e.id, new Set());
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
}, [employees]);
const matchIds = useMemo(() => {
if (query.trim().length < 2) return null;
// A focus target behaves exactly like a search hit — same ancestor
// auto-expand, same ring, same fitView in graph mode — until the user
// starts typing, at which point their own search takes over.
if (query.trim().length < 2) return focusId ? new Set([focusId]) : null;
const q = query.trim().toLowerCase();
const matches = new Set<string>();
for (const e of employees) {
@@ -47,7 +66,14 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
}
}
return matches;
}, [query, employees]);
}, [query, employees, focusId]);
// The chain above the focused person is auto-expanded, so the row only
// exists after that render — scroll once it does.
useEffect(() => {
if (!focusId) return;
focusRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [focusId, mode]);
const ancestorExpandIds = useMemo(() => {
if (!matchIds) return null;
@@ -55,7 +81,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
const toExpand = new Set<string>();
for (const id of matchIds) {
let current = byId.get(id);
while (current?.manager_id) {
while (current?.manager_id && !toExpand.has(current.manager_id)) {
toExpand.add(current.manager_id);
current = byId.get(current.manager_id);
}
@@ -63,21 +89,42 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
return toExpand;
}, [matchIds, employees]);
function toggle(id: string) {
// useCallback-stable: GraphOrgChart's layout memo depends on this
// reference, so an unstable function would force a Dagre re-layout on
// every unrelated re-render.
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
}, []);
function isExpanded(id: string): boolean {
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
}
const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]);
function renderNode(e: OrgEmployee, depth: number) {
const children = childrenByManager.get(e.id) ?? [];
const chartTree = useMemo<ChartNode[]>(() => {
function toChartNode(e: OrgEmployee, ancestors: Set<string>): ChartNode {
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
const total = totalReportsById.get(e.id) ?? 0;
const nextAncestors = new Set(ancestors).add(e.id);
return {
id: e.id,
kind: "person",
label: `${e.first_name} ${e.last_name}`,
sublabel: e.job_title,
href: `/employees/${e.id}`,
avatar: { firstName: e.first_name, lastName: e.last_name },
totalReports: total,
matched: matchIds?.has(e.id) ?? false,
children: children.map((c) => toChartNode(c, nextAncestors)),
};
}
return root.map((r) => toChartNode(r, new Set()));
}, [root, childrenByManager, totalReportsById, matchIds]);
function renderNode(e: OrgEmployee, depth: number, ancestors: Set<string>) {
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
const hasChildren = children.length > 0;
const expandedNow = isExpanded(e.id);
const isMatch = matchIds?.has(e.id) ?? false;
@@ -86,6 +133,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
return (
<div key={e.id}>
<div
ref={e.id === focusId ? focusRef : undefined}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }}
>
@@ -109,7 +157,9 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
</span>
)}
</div>
{hasChildren && expandedNow && <div>{children.map((c) => renderNode(c, depth + 1))}</div>}
{hasChildren && expandedNow && (
<div>{children.map((c) => renderNode(c, depth + 1, new Set(ancestors).add(e.id)))}</div>
)}
</div>
);
}
@@ -140,8 +190,13 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
>
Alles einklappen
</button>
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div>
<div>{root.map((r) => renderNode(r, 0))}</div>
{mode === "list" ? (
<div>{root.map((r) => renderNode(r, 0, new Set()))}</div>
) : (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} matchedIds={matchIds} />
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import "@xyflow/react/dist/style.css";
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Edge,
} from "@xyflow/react";
import { useEffect, useMemo } from "react";
import { collectVisible, layoutWithDagre } from "./graphLayout";
import { OrgChartNode, type OrgChartRFNode } from "./OrgChartNode";
import type { ChartNode, ChartNodeKind } from "./types";
// Referentially stable across renders — React Flow treats a new nodeTypes
// object as a change and remounts every custom node otherwise.
const NODE_TYPES = { orgNode: OrgChartNode };
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
person: "#d6046e",
role: "#5c2e91",
group: "#c9b3c0",
vacancy: "#f6cfe2",
};
export type GraphOrgChartProps = {
tree: ChartNode[];
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | null;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
return (
<ReactFlowProvider>
<GraphOrgChartInner {...props} />
</ReactFlowProvider>
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
const positions = layoutWithDagre(visibleNodes, visibleEdges);
const rfNodes: OrgChartRFNode[] = visibleNodes.map((n) => ({
id: n.id,
type: "orgNode",
position: positions.get(n.id) ?? { x: 0, y: 0 },
draggable: false,
data: {
chartNode: n,
expanded: n.children.length > 0 && isExpanded(n.id),
hasChildren: n.children.length > 0,
childCount: n.children.length,
onToggle,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: "smoothstep",
pathOptions: { borderRadius: 14 },
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(rfNodes);
setEdges(rfEdges);
const raf = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 300 }));
return () => cancelAnimationFrame(raf);
}, [rfNodes, rfEdges, setNodes, setEdges, fitView]);
useEffect(() => {
if (!matchedIds || matchedIds.size === 0) return;
const t = setTimeout(() => {
const ids = [...matchedIds].filter((id) => visibleNodes.some((n) => n.id === id));
if (ids.length > 0) fitView({ nodes: ids.map((id) => ({ id })), padding: 0.3, duration: 400 });
}, 150);
return () => clearTimeout(t);
}, [matchedIds, visibleNodes, fitView]);
return (
// dvh, not vh: on iOS/Android the browser chrome collapses on scroll, and
// vh is measured against the *expanded* viewport — the canvas would hang
// off the bottom of the screen for as long as the toolbar is showing.
<div className="orgchart-canvas h-[70dvh] min-h-[420px] w-full overflow-hidden rounded-lg border border-border bg-white">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
nodesDraggable={false}
nodesConnectable={false}
edgesFocusable={false}
// Deliberately NOT elementsSelectable={false}: React Flow computes
// `hasPointerEvents = isSelectable || isDraggable || onClick || …`
// and sets pointer-events:none on the node wrapper when all of them
// are off — which kills the expand button and the name link inside
// the card. Selection stays on and is just styled away in CSS.
minZoom={0.1}
maxZoom={1.75}
fitView
fitViewOptions={{ padding: 0.2 }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
<Controls showInteractive={false} />
{/* Hidden under lg via CSS: on a phone it would cover a real
fraction of the canvas for little navigational benefit. */}
<MiniMap
pannable
zoomable
ariaLabel="Übersichtskarte"
maskColor="rgba(249, 241, 245, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
nodeStrokeWidth={0}
nodeBorderRadius={3}
/>
</ReactFlow>
</div>
);
}
type OrgChartNodeDataLike = { chartNode: { kind: ChartNodeKind } };

View File

@@ -0,0 +1,11 @@
"use client";
import dynamic from "next/dynamic";
// React Flow + Dagre only ship to the client once someone actually switches
// to "Grafisch" — everyone who stays on the (default) list view never loads
// this bundle.
export const LazyGraphOrgChart = dynamic(() => import("./GraphOrgChart").then((m) => m.GraphOrgChart), {
ssr: false,
loading: () => <div className="flex h-[70vh] min-h-[480px] items-center justify-center text-sm text-ink-muted">Lädt Grafik</div>,
});

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { AsOfPicker } from "./AsOfPicker";
import { EmployeeTree } from "./EmployeeTree";
import { PositionTree } from "./PositionTree";
import { ReorgWorkbench } from "./ReorgWorkbench";
@@ -17,10 +18,29 @@ type OrgChartClientProps = {
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[];
asOf: string;
today: string;
projectedCount: number;
historyStartsAt: string | null;
/** Arrived via "Im Organigramm anzeigen" — unfold and centre this person. */
focusId: string | null;
};
export function OrgChartClient({ employees, divisions, departments, teams, openPositions, reorgScenarios }: OrgChartClientProps) {
export function OrgChartClient({
employees,
divisions,
departments,
teams,
openPositions,
reorgScenarios,
asOf,
today,
projectedCount,
historyStartsAt,
focusId,
}: OrgChartClientProps) {
const [view, setView] = useState<View>("ma");
const isToday = asOf === today;
return (
<div className="flex flex-col gap-4">
@@ -33,13 +53,31 @@ export function OrgChartClient({ employees, divisions, departments, teams, openP
{ value: "reo", label: "Reorganisation" },
]}
/>
{view === "ma" && <EmployeeTree employees={employees} />}
{view !== "reo" && (
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
)}
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
{view === "pos" && (
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
)}
{view === "reo" && (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
)}
{view === "reo" &&
(isToday ? (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
) : (
// A reorg planned against a past or projected roster would be
// applied to the *live* org anyway — better to send the user back
// to today than to let them assemble moves from a roster that is
// not the one the change would hit.
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
<p className="mt-1 text-ink-muted">
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur wechseln
Sie zurück auf Heute, um eine zu planen.
</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { NODE_DIMENSIONS } from "./graphLayout";
import type { ChartNode, ChartNodeKind } from "./types";
export type OrgChartNodeData = {
chartNode: ChartNode;
expanded: boolean;
hasChildren: boolean;
childCount: number;
onToggle: (id: string) => void;
};
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
// One accent colour per node kind, carried by a left stripe. It is what makes
// the four kinds separable at a glance when the chart is zoomed out far
// enough that the text has stopped being legible.
const KIND_ACCENT: Record<ChartNodeKind, string> = {
person: "bg-brand-500",
role: "bg-purple-text",
group: "bg-ink-muted",
vacancy: "bg-brand-200",
};
const KIND_SHELL: Record<ChartNodeKind, string> = {
person: "border-border bg-white",
role: "border-border bg-white",
group: "border-border-subtle bg-surface",
vacancy: "border-dashed border-brand-200 bg-brand-50",
};
// React Flow re-renders node components on every pan/zoom frame — memo is
// required, not just tidy, to keep that smooth at a few hundred nodes.
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports } = chartNode;
const isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind];
const content = (
<>
{kind === "person" && avatar ? (
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
) : kind === "vacancy" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
<Plus className="h-3.5 w-3.5" />
</span>
) : kind === "role" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-bg text-purple-text">
<UserRound className="h-3.5 w-3.5" />
</span>
) : (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-ink-muted">
<Building2 className="h-3.5 w-3.5" />
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span title={label} className="truncate text-[13px] font-bold leading-tight text-ink">
{label}
</span>
{sublabel && (
<span
title={sublabel}
className={`truncate text-[11px] leading-tight ${vacant ? "font-semibold text-warning-text" : "text-ink-muted"}`}
>
{sublabel}
</span>
)}
{totalReports !== undefined && totalReports > 0 && (
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
{childCount} direkt · {totalReports} gesamt
</span>
)}
</span>
</>
);
return (
<div
style={{ width, height }}
className={`group relative flex items-center overflow-visible rounded-lg border shadow-sm transition-shadow hover:shadow-md ${
KIND_SHELL[kind]
} ${isMatch ? "ring-2 ring-brand-500 ring-offset-1" : ""}`}
>
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
{/* Accent stripe, inset so it follows the card's rounded corner. */}
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
{href ? (
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
{content}
</Link>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
)}
{/* Overhangs the bottom edge, sitting on the connector to its children —
the conventional org-chart affordance, and a 28px touch target. */}
{hasChildren && (
<button
type="button"
onClick={() => onToggle(id)}
aria-expanded={expanded}
aria-label={expanded ? `${label} zuklappen` : `${label} aufklappen (${childCount})`}
className="nodrag nopan absolute -bottom-3.5 left-1/2 z-10 flex h-7 min-w-7 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-white px-1.5 text-[11px] font-bold text-ink-body shadow-sm transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : childCount}
</button>
)}
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!invisible" />
</div>
);
});

View File

@@ -2,9 +2,13 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
type ViewMode = "list" | "graph";
function TreeRow({
depth,
@@ -43,38 +47,134 @@ type PositionTreeProps = {
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
const [mode, setMode] = useState<ViewMode>("list");
function toggle(id: string) {
// useCallback-stable: GraphOrgChart's layout memo depends on these
// references, so unstable functions would force a Dagre re-layout on
// every unrelated re-render.
const toggle = useCallback((id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
}, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
const ceo = employees.find((e) => e.org_level === 0) ?? null;
const divisionHeadByDivision = new Map<string, OrgEmployee>();
const teamLeadByTeam = new Map<string, OrgEmployee>();
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
for (const e of employees) {
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
if (!e.is_lead && e.org_level === 3 && e.team_id) {
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
byTitle.get(e.job_title)!.push(e);
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
const divisionHeadByDivision = new Map<string, OrgEmployee>();
const teamLeadByTeam = new Map<string, OrgEmployee>();
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
for (const e of employees) {
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
if (!e.is_lead && e.org_level === 3 && e.team_id) {
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
byTitle.get(e.job_title)!.push(e);
}
}
}
const openByTeam = new Map<string, OpenPositionResolved[]>();
for (const p of openPositions) {
if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []);
openByTeam.get(p.team_id)!.push(p);
}
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
}, [employees]);
const openByTeam = useMemo(() => {
const map = new Map<string, OpenPositionResolved[]>();
for (const p of openPositions) {
if (!map.has(p.team_id)) map.set(p.team_id, []);
map.get(p.team_id)!.push(p);
}
return map;
}, [openPositions]);
// Mirrors the JSX walk below into the generic ChartNode shape for graph
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
// the list already uses as `expanded` keys, so one Set drives both.
const chartTree = useMemo<ChartNode[]>(() => {
if (!ceo) return [];
function buildTeam(team: OrgTeam): ChartNode {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
id: `${teamKey}-${title}`,
kind: "group",
label: title,
sublabel: `${people.length}x besetzt`,
children: people.map((p) => ({
id: p.id,
kind: "person",
label: `${p.first_name} ${p.last_name}`,
href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name },
children: [],
})),
}));
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
id: `vac-${p.id}`,
kind: "vacancy",
label: `${p.position_number} · ${p.title}`,
href: "/positions",
children: [],
}));
return {
id: teamKey,
kind: "role",
label: `Teamleitung ${team.name}`,
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
vacant: !lead,
children: [...titleGroups, ...vacancyNodes],
};
}
function buildDept(dept: OrgDepartment): ChartNode {
return {
id: `dept-${dept.id}`,
kind: "group",
label: `${dept.org_number} · ${dept.name}`,
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
};
}
function buildDivision(div: OrgDivision): ChartNode {
const head = divisionHeadByDivision.get(div.id);
return {
id: `div-${div.id}`,
kind: "role",
label: `Bereichsleitung ${div.name}`,
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
vacant: !head,
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
};
}
return [
{
id: "root",
kind: "role",
label: "Geschäftsführung",
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
children: divisions.map(buildDivision),
},
];
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
return (
<div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4">
{ceo && (
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
@@ -168,6 +268,8 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -189,7 +189,7 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
<div className="flex flex-col gap-4">
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />

View File

@@ -0,0 +1,60 @@
import dagre from "@dagrejs/dagre";
import type { ChartNode, ChartNodeKind } from "./types";
// Must match what OrgChartNode actually renders: Dagre reserves exactly this
// much space per node, so a box that grows past it overlaps its neighbour.
export const NODE_DIMENSIONS: Record<ChartNodeKind, { width: number; height: number }> = {
person: { width: 268, height: 80 },
role: { width: 268, height: 80 },
group: { width: 244, height: 62 },
vacancy: { width: 244, height: 62 },
};
export type VisibleEdge = { id: string; source: string; target: string };
// Collapsing a node means excluding its descendants here, not CSS-hiding a
// layout computed for the full tree — Dagre only ever lays out what's
// actually visible, which is what keeps this usable at ~800 employees.
export function collectVisible(tree: ChartNode[], isExpanded: (id: string) => boolean): { visibleNodes: ChartNode[]; visibleEdges: VisibleEdge[] } {
const visibleNodes: ChartNode[] = [];
const visibleEdges: VisibleEdge[] = [];
function walk(n: ChartNode) {
visibleNodes.push(n);
if (n.children.length > 0 && isExpanded(n.id)) {
for (const c of n.children) {
visibleEdges.push({ id: `${n.id}->${c.id}`, source: n.id, target: c.id });
walk(c);
}
}
}
for (const root of tree) walk(root);
return { visibleNodes, visibleEdges };
}
// Dagre returns center points; React Flow positions nodes by their top-left
// corner, hence the width/height/2 offset below.
export function layoutWithDagre(visibleNodes: ChartNode[], visibleEdges: VisibleEdge[]): Map<string, { x: number; y: number }> {
const g = new dagre.graphlib.Graph();
// ranksep leaves room for the expand button that overhangs each node's
// bottom edge; nodesep keeps sibling cards from visually merging.
g.setGraph({ rankdir: "TB", nodesep: 40, ranksep: 88 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of visibleNodes) {
const { width, height } = NODE_DIMENSIONS[n.kind];
g.setNode(n.id, { width, height });
}
for (const e of visibleEdges) g.setEdge(e.source, e.target);
dagre.layout(g);
const positions = new Map<string, { x: number; y: number }>();
for (const n of visibleNodes) {
const { width, height } = NODE_DIMENSIONS[n.kind];
const { x, y } = g.node(n.id);
positions.set(n.id, { x: x - width / 2, y: y - height / 2 });
}
return positions;
}

View File

@@ -15,3 +15,26 @@ export type OrgDivision = { id: string; org_number: string; name: string };
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
// Generic tree shape both EmployeeTree and PositionTree map their own data
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
// "role" = structural position (Geschäftsführung/Bereichsleitung/Teamleitung),
// "group" = pure grouping label (Abteilung, Jobtitel-Gruppe), "vacancy" = open
// position. EmployeeTree only ever produces "person" nodes.
export type ChartNodeKind = "person" | "role" | "group" | "vacancy";
export type ChartNode = {
id: string;
kind: ChartNodeKind;
label: string;
sublabel?: string;
href?: string;
avatar?: { firstName: string; lastName: string };
badge?: string;
matched?: boolean;
/** A structural role with nobody in it — drawn as an open slot. */
vacant?: boolean;
/** Reports below this node in total, not just direct ones. */
totalReports?: number;
children: ChartNode[];
};

View File

@@ -6,6 +6,7 @@ import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/ac
import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type Team = Database["public"]["Tables"]["teams"]["Row"];
@@ -17,6 +18,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
const [isLead, setIsLead] = useState(false);
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
const [teamId, setTeamId] = useState("");
const [validFrom, setValidFrom] = useState(todayIso);
const [pending, setPending] = useState(false);
function reset() {
@@ -24,10 +26,11 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
setIsLead(false);
setSuperior(null);
setTeamId("");
setValidFrom(todayIso());
}
async function handleSubmit() {
if (!title || !superior || (isLead && !teamId)) {
if (!title || !superior || !validFrom || (isLead && !teamId)) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
@@ -37,6 +40,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
superior_employee_id: superior.id,
is_lead: isLead,
team_id: isLead ? teamId : undefined,
valid_from: validFrom,
});
setPending(false);
if (result.success) {
@@ -130,6 +134,15 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Gültig ab*</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
</div>
</Modal>
);

View File

@@ -1,53 +1,51 @@
"use client";
import { Plus } from "lucide-react";
import Link from "next/link";
import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHireWizard } from "@/components/hire/HireWizardContext";
import { deletePosition } from "@/actions/positions";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal } from "./CreatePositionModal";
import { StaffInternallyModal } from "./StaffInternallyModal";
type Team = {
id: string;
org_number: string;
name: string;
lead: { id: string; name: string } | null;
headcount: number;
fte: number;
openCount: number;
};
type Department = { id: string; org_number: string; name: string; teams: Team[] };
type DivisionCard = {
id: string;
org_number: string;
name: string;
head: { id: string; name: string } | null;
headcount: number;
departments: Department[];
};
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[];
divisionCards: DivisionCard[];
teams: { id: string; org_number: string; name: string; department_id: string }[];
};
export function PositionsPageClient({ openPositions, divisionCards, teams }: PositionsPageClientProps) {
const { openWizard } = useHireWizard();
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false);
const [staffTarget, setStaffTarget] = useState<{ id: string; title: string; is_lead: boolean } | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const today = todayIso();
async function handleDelete(id: string) {
setDeletingId(id);
const result = await deletePosition(id);
setDeletingId(null);
if (result.success) {
showToast("Position gelöscht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Löschen.", "error");
}
}
return (
<div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
<Plus className="h-4 w-4" />
Position ausschreiben
@@ -57,96 +55,43 @@ export function PositionsPageClient({ openPositions, divisionCards, teams }: Pos
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => (
<div key={p.id} className="rounded border border-border p-3">
<div className="text-sm font-semibold text-ink">{p.title}</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
<div className="mt-3 flex gap-2">
{openPositions.map((p) => {
const notYetValid = p.valid_from > today;
return (
<div key={p.id} className="flex flex-col rounded border border-border p-3">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-semibold text-ink">{p.title}</div>
<button
type="button"
onClick={() => handleDelete(p.id)}
disabled={deletingId === p.id}
aria-label="Position löschen"
className="-mr-1 -mt-1 rounded p-2 text-ink-muted hover:text-danger-solid disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel}
</div>
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
<button
type="button"
onClick={() => setStaffTarget({ id: p.id, title: p.title, is_lead: p.is_lead })}
className="flex-1 rounded bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-600"
disabled={notYetValid}
title={notYetValid ? `Position ist erst ab ${fmtDate(p.valid_from)} gültig.` : undefined}
className="mt-3 w-full rounded bg-brand-500 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
>
Intern
</button>
<button
type="button"
onClick={() => openWizard({ positionId: p.id })}
className="flex-1 rounded border border-border px-3 py-1.5 text-xs font-semibold text-ink-body hover:bg-surface"
>
Extern
Besetzen
</button>
</div>
</div>
))}
);
})}
</div>
)}
</div>
{divisionCards.map((div) => (
<div key={div.id} className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
<div>
<div className="text-xs text-ink-muted">{div.org_number}</div>
<div className="text-sm font-bold text-ink">{div.name}</div>
{div.head && (
<Link href={`/employees/${div.head.id}`} className="text-xs text-brand-700 hover:underline">
{div.head.name}
</Link>
)}
</div>
<div className="text-sm font-semibold text-ink">{div.headcount} Mitarbeiter:innen</div>
</div>
<div className="flex flex-col gap-4">
{div.departments.map((dept) => (
<div key={dept.id}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{dept.org_number} · {dept.name}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="text-left text-xs text-ink-muted">
<th className="py-1 pr-3">Team</th>
<th className="py-1 pr-3">Teamleitung</th>
<th className="py-1 pr-3">Headcount</th>
<th className="py-1 pr-3">FTE</th>
<th className="py-1 pr-3">Offen</th>
</tr>
</thead>
<tbody>
{dept.teams.map((team) => (
<tr key={team.id} className="border-t border-border">
<td className="py-1.5 pr-3">
<div className="text-ink">{team.name}</div>
<div className="text-xs text-ink-muted">{team.org_number}</div>
</td>
<td className="py-1.5 pr-3 text-ink-body">
{team.lead ? (
<Link href={`/employees/${team.lead.id}`} className="hover:text-brand-700 hover:underline">
{team.lead.name}
</Link>
) : (
""
)}
</td>
<td className="py-1.5 pr-3 text-ink-body">{team.headcount}</td>
<td className="py-1.5 pr-3 text-ink-body">{team.fte.toFixed(1)}</td>
<td className="py-1.5 pr-3 font-semibold text-brand-700">{team.openCount || ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
))}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
{staffTarget && (
<StaffInternallyModal

View File

@@ -28,7 +28,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id });
setPending(false);
if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde intern besetzt.`);
showToast(`${employee.first_name} ${employee.last_name} besetzt die Position.`);
router.refresh();
onClose();
setEmployee(null);
@@ -41,7 +41,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
<Modal
open={open}
onClose={onClose}
title="Intern besetzen"
title="Position besetzen"
footer={
<>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">

View File

@@ -18,6 +18,7 @@ import {
MEASURE_LABELS,
parseStatuses,
REPORT_PRESETS,
sortKeysForDimension,
STATUS_OPTIONS,
todayIso,
type EventGroupDimension,
@@ -86,11 +87,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
if (v) sp.set(k, v);
else sp.delete(k);
}
router.push(`${pathname}?${sp.toString()}`);
router.push(`${pathname}?${sp.toString()}`, { scroll: false });
}
function switchMode(next: "snapshot" | "events") {
router.push(`${pathname}?mode=${next}`);
router.push(`${pathname}?mode=${next}`, { scroll: false });
}
function toggleStatus(status: string) {
@@ -106,7 +107,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
sp.set("group", preset.group);
if (preset.split) sp.set("split", preset.split);
if (preset.eventType) sp.set("eventType", preset.eventType);
router.push(`${pathname}?${sp.toString()}`);
router.push(`${pathname}?${sp.toString()}`, { scroll: false });
}
function applySavedReport(config: Record<string, unknown>) {
@@ -115,7 +116,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
if (typeof v === "string" && v) sp.set(k, v);
}
if (!sp.get("mode")) sp.set("mode", "snapshot");
router.push(`${pathname}?${sp.toString()}`);
router.push(`${pathname}?${sp.toString()}`, { scroll: false });
}
async function handleConfirmSaveReport() {
@@ -189,6 +190,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
const maxValue = Math.max(1, ...rows.map((r) => r.value));
const rawSplitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? [])));
const splitKeys = mode === "snapshot" && props.split ? sortKeysForDimension(rawSplitKeys, props.split) : rawSplitKeys;
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
const currentYear = new Date().getFullYear();
@@ -508,10 +512,15 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div>
<p className="mb-4 text-2xl font-extrabold text-ink">{totalDisplay}</p>
{showWeekdayMultiCountNote && (
<p className="mb-3 text-xs text-ink-muted">
Mitarbeitende mit mehreren Arbeitstagen zählen bei Wochentag an jedem ihrer Arbeitstage mehrfach Summe und Anteile ergeben daher mehr als den Gesamt-Headcount bzw. 100%.
</p>
)}
{((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && (
<div className="mb-3 flex flex-wrap gap-3">
{Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))).map((key, i) => (
{splitKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1.5 text-xs text-ink-body">
<span className={`h-2.5 w-2.5 rounded-full ${SPLIT_COLORS[i % SPLIT_COLORS.length]}`} />
{key}

View File

@@ -0,0 +1,26 @@
"use client";
import { useCallback, useState, type ReactNode } from "react";
import type { OpenNote } from "@/lib/notes";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
// Owns the one piece of state the shell needs (is the mobile drawer open),
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
// auth check and data loading on the server.
export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) {
const [navOpen, setNavOpen] = useState(false);
const closeNav = useCallback(() => setNavOpen(false), []);
return (
<div className="min-h-dvh">
<Sidebar open={navOpen} onClose={closeNav} />
<div className="flex min-h-dvh flex-col lg:ml-[236px]">
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Bell } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { completeEmployeeNote } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { OpenNote } from "@/lib/notes";
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
// listener) — without its draft-text reset, which has no equivalent here.
export function NotesBell({ notes }: { notes: OpenNote[] }) {
const { showToast } = useToast();
const router = useRouter();
const [open, setOpen] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
async function handleComplete(note: OpenNote) {
setCompletingId(note.id);
const result = await completeEmployeeNote({ note_id: note.id, employee_id: note.employee_id });
setCompletingId(null);
if (result.success) {
showToast("Notiz erledigt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<div ref={containerRef} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-ink-muted hover:bg-surface">
<Bell className="h-4 w-4" />
{notes.length > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-solid px-1 text-[10px] font-bold text-white">
{notes.length}
</span>
)}
</button>
{open && (
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-lg">
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
{notes.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
) : (
<ul className="divide-y divide-border">
{notes.map((n) => (
<li key={n.id} className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
<Link href={`/employees/${n.employee_id}`} className="text-sm font-semibold text-ink hover:text-brand-700 hover:underline">
{n.employeeName}
</Link>
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
</div>
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
<button
type="button"
onClick={() => handleComplete(n)}
disabled={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
>
Erledigt
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -1,44 +1,91 @@
"use client";
import { BarChart3, Building2, History, LayoutGrid, Network, Users } from "lucide-react";
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
const NAV_ITEMS = [
{ href: "/", label: "Übersicht", icon: LayoutGrid },
{ href: "/employees", label: "Mitarbeiter:innen", icon: Users },
{ href: "/orgchart", label: "Organigramm", icon: Network },
{ href: "/positions", label: "Positionen & Bereiche", icon: Building2 },
{ href: "/positions", label: "Positionen", icon: Building2 },
{ href: "/reports", label: "Berichte", icon: BarChart3 },
{ href: "/audit", label: "Audit-Log", icon: History },
] as const;
export function Sidebar() {
export function isActiveRoute(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname.startsWith(href);
}
// Permanent rail from lg up, slide-in drawer below it. Same markup either
// way — the breakpoint only changes how the <nav> is positioned — so the
// nav list has exactly one definition.
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const pathname = usePathname();
// A route change means the user tapped a nav item; the drawer has to get
// out of the way on its own, since it covers the page it just navigated to.
useEffect(() => {
onClose();
}, [pathname, onClose]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<nav className="fixed left-0 top-0 flex h-full w-[236px] flex-col border-r border-border bg-white">
<div className="flex h-14 items-center border-b border-border px-5">
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
</div>
<ul className="flex-1 space-y-1 px-3 py-4">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<li key={href}>
<Link
href={href}
className={`flex items-center gap-3 rounded px-3 py-2 text-sm font-semibold transition-colors ${
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
}`}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
</li>
);
})}
</ul>
</nav>
<>
<div
onClick={onClose}
aria-hidden
className={`fixed inset-0 z-40 bg-black/40 transition-opacity lg:hidden ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<nav
aria-label="Hauptnavigation"
className={`fixed left-0 top-0 z-50 flex h-dvh w-[264px] flex-col border-r border-border bg-white transition-transform duration-200 lg:z-30 lg:w-[236px] lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-5 pr-3">
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
<button
type="button"
onClick={onClose}
aria-label="Navigation schließen"
className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{/* pb keeps the last item clear of the iOS home indicator. */}
<ul className="flex-1 space-y-1 overflow-y-auto px-3 py-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const active = isActiveRoute(pathname, href);
return (
<li key={href}>
<Link
href={href}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-3 rounded px-3 py-2.5 text-sm font-semibold transition-colors ${
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
}`}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}

View File

@@ -1,15 +1,17 @@
"use client";
import { LogOut } from "lucide-react";
import { LogOut, Menu } from "lucide-react";
import { usePathname } from "next/navigation";
import { logout } from "@/actions/auth";
import type { OpenNote } from "@/lib/notes";
import { NewHireButton } from "./NewHireButton";
import { NotesBell } from "./NotesBell";
const TITLES: Record<string, string> = {
"/": "Übersicht",
"/employees": "Mitarbeiter:innen",
"/orgchart": "Organigramm",
"/positions": "Positionen & Bereiche",
"/positions": "Positionen",
"/reports": "Berichte",
"/audit": "Audit-Log",
};
@@ -22,20 +24,37 @@ function titleFor(pathname: string): string {
type TopbarProps = {
userLabel: string;
openNotes: OpenNote[];
onOpenNav: () => void;
};
export function Topbar({ userLabel }: TopbarProps) {
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
const pathname = usePathname();
return (
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
<div className="flex items-center gap-4">
// Sticky, with the notch inset added to its top padding: on an iPhone in
// landscape the bar would otherwise sit under the rounded corner.
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-border bg-white px-3 pt-[env(safe-area-inset-top)] sm:px-6">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={onOpenNav}
aria-label="Navigation öffnen"
className="-ml-1 rounded p-2 text-ink-body hover:bg-surface lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
<NotesBell notes={openNotes} />
<NewHireButton />
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
<span className="font-semibold text-ink">{userLabel}</span>
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
{/* The name is the first thing worth dropping on a narrow screen —
the account is still reachable via the sign-out control. */}
<span className="hidden font-semibold text-ink md:inline">{userLabel}</span>
<form action={logout}>
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface">
<button type="submit" aria-label="Abmelden" className="rounded p-2 text-ink-muted hover:bg-surface">
<LogOut className="h-4 w-4" />
</button>
</form>

View File

@@ -25,21 +25,27 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className={`flex max-h-[85vh] w-full flex-col rounded bg-white shadow-xl ${widthClassName}`}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
// Bottom-aligned on a phone (thumb reach, and it clears the keyboard when
// a field is focused), centred from sm up.
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4">
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
>
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 className="text-lg font-bold text-ink">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="rounded p-1 text-ink-muted hover:bg-surface"
>
<button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-4">
{footer}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
// Shared by the employee list and the audit log, which both used to render
// one link per page: 54 of them for ~800 employees, and an unbounded number
// for the audit log, all in a single overflowing row. This shows the first
// and last page plus a window around the current one, with the gaps elided.
const WINDOW = 2;
function pageItems(current: number, total: number): number[] {
const pages = new Set<number>([1, total]);
for (let p = current - WINDOW; p <= current + WINDOW; p++) {
if (p >= 1 && p <= total) pages.add(p);
}
return [...pages].sort((a, b) => a - b);
}
type PaginationProps = {
page: number;
totalPages: number;
hrefFor: (page: number) => string;
/** Screen-reader name, e.g. "Mitarbeiter:innen" — several lists per app. */
label: string;
};
export function Pagination({ page, totalPages, hrefFor, label }: PaginationProps) {
if (totalPages <= 1) return null;
const current = Math.min(Math.max(1, page), totalPages);
const items = pageItems(current, totalPages);
return (
<nav aria-label={`Seiten ${label}`} className="flex flex-wrap items-center justify-center gap-1 text-sm">
{current > 1 && (
<Link href={hrefFor(current - 1)} rel="prev" className="rounded px-3 py-1 font-semibold text-ink-body hover:bg-surface">
Zurück
</Link>
)}
{items.map((p, i) => (
<span key={p} className="flex items-center gap-1">
{i > 0 && p - items[i - 1] > 1 && <span className="px-1 text-ink-muted"></span>}
<Link
href={hrefFor(p)}
aria-current={p === current ? "page" : undefined}
className={`rounded px-3 py-1 ${p === current ? "bg-brand-500 font-semibold text-white" : "text-ink-body hover:bg-surface"}`}
>
{p}
</Link>
</span>
))}
{current < totalPages && (
<Link href={hrefFor(current + 1)} rel="next" className="rounded px-3 py-1 font-semibold text-ink-body hover:bg-surface">
Weiter
</Link>
)}
</nav>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { X } from "lucide-react";
// Dropdown-to-add, chip-to-remove multi-select for short, fixed option
// lists (no search needed) — e.g. academic titles. The dropdown only offers
// options not yet picked; the reset key forces the <select> back to its
// placeholder after each pick instead of showing the just-added option.
export function Picklist({
options,
value,
onChange,
placeholder = "Hinzufügen…",
}: {
options: string[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
}) {
const available = options.filter((o) => !value.includes(o));
function remove(option: string) {
onChange(value.filter((v) => v !== option));
}
return (
<div className="flex flex-col gap-2">
<select
key={value.length}
defaultValue=""
onChange={(e) => {
if (e.target.value) onChange([...value, e.target.value]);
}}
disabled={available.length === 0}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
>
<option value="" disabled>
{available.length > 0 ? placeholder : "Alle Optionen ausgewählt"}
</option>
{available.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{value.map((v) => (
<span key={v} className="flex items-center gap-1 rounded-full bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white">
{v}
<button type="button" onClick={() => remove(v)} aria-label={`${v} entfernen`} className="text-white/80 hover:text-white">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -29,26 +29,28 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
onClick={onClose}
/>
<div
className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${
role="dialog"
aria-modal="true"
aria-label={title}
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-start justify-between border-b border-border px-6 py-4">
<div>
<div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4">
<div className="min-w-0">
<h2 className="text-lg font-bold text-ink">{title}</h2>
{subtitle && <p className="text-sm text-ink-muted">{subtitle}</p>}
{subtitle && <p className="truncate text-sm text-ink-muted">{subtitle}</p>}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="rounded p-1 text-ink-muted hover:bg-surface"
>
<button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-4">
{footer}
</div>
)}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
type ToastVariant = "success" | "error" | "info";
type ToastItem = { id: number; message: string; variant: ToastVariant };
@@ -23,8 +23,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
// This provider wraps the whole app, so an inline object literal here would
// hand every consumer a new context value on each toast and re-render the
// entire tree for a 4-second banner.
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={{ showToast }}>
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{toasts.map((t) => (