Anforderung 11 aus dem Workshop. Migration 20260915160000. In der Fertigung sind Planstellen reihenweise gleich: zwoelf "Maschinenbediener:in" in derselben Abteilung auf derselben Kostenstelle. Von Hand angelegt sind das zwoelf Gelegenheiten, die Taetigkeit unterschiedlich zu schreiben — und ab der zweiten Schreibweise steht sie zweimal im Katalog und jede Auswertung nach Taetigkeit ist falsch. Der Klon nimmt Einheit, Taetigkeit (denselben Katalogeintrag) und die zum Stichtag geltende Kontierung. Nicht mit kommt die Besetzung: eine Planstelle ist ein Platz, keine Person, der Klon ist frei. Leitungsplanstellen sind ausgenommen, und zwar mit einer eigenen Meldung. Je Einheit gibt es genau eine, und ein Unique-Index sichert das ab — ohne die Pruefung waere ein Klonversuch entweder "duplicate key value violates unique constraint" oder, mit stillschweigend fallengelassenem is_chief, eine Planstelle, die anders ist als ihre Vorlage, ohne dass es jemand angefordert hat. In der Liste fehlt der Knopf dort; das ist Bequemlichkeit, die Regel steht in der Funktion.
176 lines
7.2 KiB
TypeScript
176 lines
7.2 KiB
TypeScript
"use client";
|
|
|
|
import { Copy, Plus, Trash2 } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { clonePosition, deletePosition } from "@/actions/positions";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { useToast } from "@/components/ui/Toast";
|
|
import { fmtDate, todayIso } from "@/lib/format";
|
|
import type { OpenPositionResolved } from "@/lib/positions";
|
|
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
|
import { EditPositionModal } from "./EditPositionModal";
|
|
|
|
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
|
|
|
type PositionsPageClientProps = {
|
|
openPositions: OpenPositionWithDays[];
|
|
units: UnitOption[];
|
|
kostenstellen: { id: string; code: string; name: string }[];
|
|
};
|
|
|
|
export function PositionsPageClient({ openPositions, units, kostenstellen }: PositionsPageClientProps) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [cloningId, setCloningId] = useState<string | null>(null);
|
|
|
|
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
|
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
|
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
|
|
const offen = openPositions.filter((p) => !p.future);
|
|
const kuenftig = openPositions.filter((p) => p.future);
|
|
|
|
async function handleClone(p: OpenPositionWithDays) {
|
|
setCloningId(p.id);
|
|
const result = await clonePosition({ position_id: p.id });
|
|
setCloningId(null);
|
|
if (result.success) {
|
|
showToast(`Planstelle ${p.position_number} kopiert.`);
|
|
router.refresh();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Klonen.", "error");
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
setDeletingId(id);
|
|
const result = await deletePosition(id);
|
|
setDeletingId(null);
|
|
if (result.success) {
|
|
showToast("Planstelle entfernt.");
|
|
router.refresh();
|
|
} else {
|
|
showToast(result.error ?? "Fehler beim Löschen.", "error");
|
|
}
|
|
}
|
|
|
|
function Karte({ p }: { p: OpenPositionWithDays }) {
|
|
return (
|
|
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
|
|
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
|
|
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
|
|
der Tastatur nicht erreichbar. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(p)}
|
|
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
|
|
className="flex flex-col items-start rounded p-3 pr-10 text-left
|
|
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
<span className="text-sm font-semibold text-ink">{p.title}</span>
|
|
<span className="text-xs text-ink-muted">
|
|
{p.position_number} · {p.orgLabel}
|
|
</span>
|
|
<span className="mt-1 text-xs text-ink-muted">
|
|
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
|
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
|
|
</span>
|
|
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
|
|
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
|
|
{/* Was die Stelle kostet und wen es trifft — die Frage, die bei einer
|
|
Vakanz zuerst kommt. */}
|
|
{p.kostenstelle && (
|
|
<span className="text-xs text-ink-muted">
|
|
Kostenstelle <span className="font-semibold tabular-nums text-ink-body">{p.kostenstelle.code}</span> ·{" "}
|
|
{p.kostenstelle.name}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<span className="absolute right-2 top-2 flex items-center gap-0.5">
|
|
{/* Nicht bei einer Leitung: je Einheit gibt es genau eine, ein Klon
|
|
wäre also entweder ein Fehler aus dem Maschinenraum oder eine
|
|
Planstelle, die stillschweigend anders ist als ihre Vorlage. Die
|
|
SQL-Funktion weist es ohnehin ab — der fehlende Knopf erspart
|
|
den Weg dorthin. */}
|
|
{!p.is_chief && (
|
|
<Button
|
|
variant="icon"
|
|
onClick={() => handleClone(p)}
|
|
pending={cloningId === p.id}
|
|
aria-label={`Planstelle ${p.position_number} (${p.title}) klonen`}
|
|
title="Klonen — gleiche Einheit, Tätigkeit und Kostenstelle, neue Nummer"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="icon"
|
|
onClick={() => handleDelete(p.id)}
|
|
pending={deletingId === p.id}
|
|
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
|
className="hover:!text-danger-solid"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<section className="rounded border border-border bg-white p-4">
|
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
|
|
<Button onClick={() => setCreateOpen(true)}>
|
|
<Plus className="h-4 w-4" />
|
|
Planstelle anlegen
|
|
</Button>
|
|
</div>
|
|
{offen.length === 0 ? (
|
|
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{offen.map((p) => (
|
|
<Karte key={p.id} p={p} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{kuenftig.length > 0 && (
|
|
<section className="rounded border border-border bg-white p-4">
|
|
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
|
|
<p className="mb-3 mt-1 text-xs text-ink-muted">
|
|
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
|
|
</p>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{kuenftig.map((p) => (
|
|
<Karte key={p.id} p={p} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
|
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
|
|
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
|
|
stehen. */}
|
|
{editing && (
|
|
<EditPositionModal
|
|
key={editing.id}
|
|
position={editing}
|
|
units={units}
|
|
kostenstellen={kostenstellen}
|
|
heute={todayIso()}
|
|
onClose={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|