Files
alpenwerk-hr/components/employees/tabs/OrganisationTab.tsx
Maximilian Stubhan e30923e781 Give a position a cost centre
There was none anywhere in the model, so no personnel-cost figure could
be produced at all, and an open position could not say whose budget it
would charge — which is the first question asked about a vacancy.

It hangs on the position, not on the person: the seat costs money even
when nobody sits on it. That is exactly the vacancy case. And not on the
org unit either, although it usually follows from one — a single seat
can be charged elsewhere (project, shared function) without the unit
moving.

As its own dated assignment table rather than a column, because
reassigning is an event with a date. Last year's costs have to stay
where they were incurred; as a column, every change would silently
rewrite every past report. Half-open [valid_from, valid_to), like
position_assignments and om_positions — in SAP OM this is A011.

25 cost centres seeded from the org tree: one per company, division and
department, with teams charging to their department, because a team is a
span of control and not a budget. All 823 positions were assigned from
their own start date, none left over. The number is the first five digits
of the org number, so it can be traced rather than looked up.

Reassignment refuses three things, each checked: the same cost centre
again, a switch on the day the current one started (that period would
never have been in force, and the range constraint says so), and a date
before the position exists.

Verified against the real data, which turned up a defect worth keeping:
a position that starts in the future is charged only from its start, so
asked about today it had no cost centre — and future positions are
exactly what the vacancy list is for. It is now read at the position's
own start date.

Two audit entries from the probe could not be deleted through the
application (the log has no delete policy — correctly), so I removed
them with the admin connection.

Still open, and the reason this is only the first of the three fields I
proposed: location and planned FTE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:57:09 +02:00

107 lines
4.3 KiB
TypeScript

import { Network } from "lucide-react";
import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar";
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { fmtName } from "@/lib/format";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OrganisationTabProps = {
employeeId: string;
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[];
breadcrumb: string;
/**
* Die Kostenstelle der Planstelle, auf der die Person heute sitzt — nicht
* ihre eigene: sie kontiert dorthin, wo ihr Sitz kontiert ist. Fehlt sie,
* hat die Person keine laufende Besetzung (geplanter Eintritt, Austritt).
*/
kostenstelle: { code: string; name: string } | null;
};
export function OrganisationTab({
employeeId,
manager,
formalManager,
directReports,
breadcrumb,
kostenstelle,
}: OrganisationTabProps) {
return (
<div className="flex flex-col gap-6">
<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>
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-ink-muted">Kostenstelle</h3>
<p className="mt-1 text-sm text-ink">
{kostenstelle ? (
<>
<span className="font-semibold tabular-nums">{kostenstelle.code}</span>
<span className="text-ink-body"> · {kostenstelle.name}</span>
</>
) : (
<span className="text-ink-muted">Keine laufende Planstellenbesetzung</span>
)}
</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={LINK_BUTTON_CLASS}>
<Network className="h-4 w-4" />
Im Organigramm anzeigen
</Link>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
</h3>
{formalManager && (
<p className="mb-2 text-xs text-ink-muted">
Zuständig ist {fmtName(formalManager.first_name, formalManager.last_name)}; während der Abwesenheit übernimmt die nächste
besetzte Ebene.
</p>
)}
{manager ? (
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
<div>
<div className="text-sm font-semibold text-ink">
{fmtName(manager.first_name, manager.last_name)}
</div>
<div className="text-xs text-ink-muted">{manager.job_title}</div>
</div>
</Link>
) : (
<p className="text-sm text-ink-muted">Keine Führungskraft (Geschäftsführung)</p>
)}
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkte Berichte ({directReports.length})</h3>
{directReports.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{directReports.map((r) => (
<Link key={r.id} href={`/employees/${r.id}`} className="flex items-center gap-3 rounded border border-border p-3 hover:bg-surface">
<Avatar firstName={r.first_name} lastName={r.last_name} />
<div>
<div className="text-sm font-semibold text-ink">
{fmtName(r.first_name, r.last_name)}
</div>
<div className="text-xs text-ink-muted">{r.job_title}</div>
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-ink-muted">Keine direkten Berichte.</p>
)}
</div>
</div>
);
}