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:
144
components/employees/AddDependentModal.tsx
Normal file
144
components/employees/AddDependentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
components/employees/AngehoerigeSection.tsx
Normal file
96
components/employees/AngehoerigeSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
89
components/employees/RoleEmploymentFields.tsx
Normal file
89
components/employees/RoleEmploymentFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
components/employees/TitleFields.tsx
Normal file
24
components/employees/TitleFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
134
components/employees/tabs/NotizenTab.tsx
Normal file
134
components/employees/tabs/NotizenTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
76
components/orgchart/AsOfPicker.tsx
Normal file
76
components/orgchart/AsOfPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
139
components/orgchart/GraphOrgChart.tsx
Normal file
139
components/orgchart/GraphOrgChart.tsx
Normal 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 } };
|
||||
11
components/orgchart/LazyGraphOrgChart.tsx
Normal file
11
components/orgchart/LazyGraphOrgChart.tsx
Normal 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>,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
122
components/orgchart/OrgChartNode.tsx
Normal file
122
components/orgchart/OrgChartNode.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
60
components/orgchart/graphLayout.ts
Normal file
60
components/orgchart/graphLayout.ts
Normal 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;
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
26
components/shell/AppShell.tsx
Normal file
26
components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
components/shell/NotesBell.tsx
Normal file
89
components/shell/NotesBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
57
components/ui/Pagination.tsx
Normal file
57
components/ui/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/ui/Picklist.tsx
Normal file
60
components/ui/Picklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user