Files
alpenwerk-hr/components/dashboard/DraftsCard.tsx
Maximilian Stubhan 99e4357c02
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m18s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m45s
Let colleagues finish each other's drafts, one at a time
Seeing a colleague's draft turned out to be half a feature: the point of
sharing it is to finish it while they are away. So writing is allowed
now -- but never by two people at once.

A draft is a single JSONB field. Whoever saves writes the whole state,
not the changed field, so two open wizards overwrite each other
completely and the second person sees nothing wrong: their own state is
right there on screen. That is why writing stayed with the owner until
now, and a lock is what makes giving that up safe.

The lock lives in the row (locked_by, locked_at) and is enforced by the
update and delete policies, not by the application. It expires, and that
is the important half: releasing happens when the wizard closes, and a
closed laptop never closes a wizard. Without expiry one crashed tab
would take a draft away for good -- worse than the problem being solved.
The wizard refreshes its lock while open so a long form does not lose it
mid-way.

Delete had to widen too, which reads like more than was asked for: the
wizard deletes the draft once the person is hired. Without it the hire
would go through and the draft would sit there forever. The card still
only offers delete on your own drafts.

Four of five mutations against the lock go red. The fifth -- dropping
`!open` from the refresh guard -- does not, because freigeben() already
nulls the ref the interval checks. The condition stays as the readable
statement of intent, now with a comment saying so.

Not run against a live database here; the CI migration job is the first
real execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:43:43 +02:00

104 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Lock, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { deleteHireDraft } from "@/actions/hireDrafts";
import { useHireWizard } from "@/components/hire/HireWizardContext";
import { useToast } from "@/components/ui/Toast";
import type { Entwurf } from "@/lib/entwuerfe";
import { fmtDate } from "@/lib/format";
// Angefangene Neueinstellungen — die eigenen und, wer in der Glocke
// hinzugewählt ist, auch dessen.
//
// Fortsetzen geht bei beiden: wer vertreten wird, soll die angefangene
// Einstellung zu Ende bringen können. Aber immer nur eine Person zugleich —
// der Entwurf ist ein einziges JSONB-Feld, zwei gleichzeitig Schreibende
// überschrieben einander vollständig, und die Zweite merkte nichts davon.
// Die Sperre dazu steht in der Datenbank (20260910160000); hier steht nur,
// was sie für die Zeile bedeutet.
//
// Löschen bleibt beim Eigentümer. Die Regel liesse mehr zu — sie muss, weil
// der Assistent den Entwurf nach dem Anlegen selbst wegräumt —, aber eine
// Schaltfläche, die fremde halbfertige Arbeit wegwirft, gehört nicht neben
// eine, die sie fortsetzt.
export function DraftsCard({ drafts }: { drafts: Entwurf[] }) {
const { openWizard } = useHireWizard();
const { showToast } = useToast();
const router = useRouter();
const [pending, startTransition] = useTransition();
if (drafts.length === 0) return null;
function handleDelete(id: string) {
startTransition(async () => {
const result = await deleteHireDraft(id);
if (result.success) {
showToast("Entwurf gelöscht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Löschen.", "error");
}
});
}
return (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Entwürfe – Neueinstellungen</h2>
<ul className="flex flex-col divide-y divide-border">
{drafts.map((d) => {
const firstName = typeof d.payload.firstName === "string" ? d.payload.firstName : "";
const lastName = typeof d.payload.lastName === "string" ? d.payload.lastName : "";
const name = [firstName, lastName].filter(Boolean).join(" ") || "Ohne Namen";
return (
<li key={d.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<div>
<span className="font-semibold text-ink">{name}</span>
<span className="ml-2 text-xs text-ink-muted">Gespeichert am {fmtDate(d.updated_at)}</span>
{/* An jeder Zeile, wie an den Notizen in der Glocke: seit
fremde Entwürfe dazwischenstehen, ist ohne die Angabe nicht
zu sehen, welcher wessen ist. „von mir" statt des eigenen
Namens — den kennt man. */}
<span className="ml-2 text-xs text-ink-muted">{d.vonMir ? "von mir" : `von ${d.autor}`}</span>
</div>
<div className="flex items-center gap-3">
{d.gesperrtVon ? (
// Mit dem Grund daneben, nicht bloss abgeschaltet: eine
// graue Schaltfläche ohne Erklärung liest sich wie ein
// Fehler, und die nächste Handlung wäre, es gleich noch
// einmal zu versuchen.
<span className="flex shrink-0 items-center gap-1 text-xs text-ink-muted">
<Lock className="h-3.5 w-3.5" />
{d.gesperrtVon} bearbeitet gerade
</span>
) : (
<button
type="button"
onClick={() => openWizard({ draftId: d.id })}
className="text-xs font-semibold text-brand-700 hover:underline"
>
Fortsetzen
</button>
)}
{d.vonMir && (
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={pending || Boolean(d.gesperrtVon)}
aria-label="Entwurf löschen"
className="text-ink-muted hover:text-danger-solid disabled:opacity-40"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}