Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
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";
|
|
|
|
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
|
|
|
type PositionsPageClientProps = {
|
|
openPositions: OpenPositionWithDays[];
|
|
teams: { id: string; org_number: string; name: string; department_id: string }[];
|
|
};
|
|
|
|
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
|
|
const { showToast } = useToast();
|
|
const router = useRouter();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
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 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-2 text-sm font-semibold text-white hover:bg-brand-600"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Position ausschreiben
|
|
</button>
|
|
</div>
|
|
{openPositions.length === 0 ? (
|
|
<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) => {
|
|
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>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
|
</div>
|
|
);
|
|
}
|