SVNR validation, CI, and a dependency/security pass
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).
This commit is contained in:
90
.github/workflows/ci.yml
vendored
Normal file
90
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Lint, Typen, Tests, Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# Catches the drift that `tsc` cannot: lib/supabase/types.ts is
|
||||
# hand-written, so a migration adding a column leaves it silently stale.
|
||||
- name: Schema/Typen-Abgleich
|
||||
run: npm run types:check
|
||||
|
||||
- name: Unit- und Komponententests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
# Read at module scope by the Supabase browser client, so the build
|
||||
# needs them present — never the real project's values.
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: build-time-placeholder
|
||||
|
||||
integration:
|
||||
name: Integrationstests (echtes Postgres)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
# Applies every migration to a fresh database — which also means a
|
||||
# migration that cannot be replayed from scratch fails here rather than
|
||||
# on a restore or a new environment.
|
||||
- name: Supabase starten
|
||||
run: supabase start
|
||||
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
||||
# them under its own names.
|
||||
- name: Testumgebung schreiben
|
||||
run: |
|
||||
supabase status -o env \
|
||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
|
||||
| tr -d '"' > .env.test.local
|
||||
|
||||
- name: Seed
|
||||
run: node --env-file=.env.test.local supabase/seed.ts
|
||||
|
||||
- name: Integrationstests
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Supabase-Logs bei Fehlschlag
|
||||
if: failure()
|
||||
run: supabase status && docker ps -a
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,6 +46,10 @@ next-env.d.ts
|
||||
/supabase/.temp
|
||||
/supabase/snippets
|
||||
|
||||
# scratch output of `npm run types:generate`, for comparing against the
|
||||
# hand-written lib/supabase/types.ts — never itself imported
|
||||
/lib/supabase/types.generated.ts
|
||||
|
||||
# per-machine (permission allowlists) and must stay out of the repo
|
||||
|
||||
# playwright
|
||||
|
||||
@@ -9,7 +9,7 @@ type ActionResult = { success: boolean; error?: string };
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "delete_position" | "staff_position_internally",
|
||||
fn: "create_position" | "delete_position",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
@@ -34,13 +34,6 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export async function staffPositionInternally(payload: {
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("staff_position_internally", payload, [...POSITION_PATHS, "/employees", `/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||
|
||||
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||
|
||||
@@ -151,7 +151,13 @@ 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} dependents={dependents} />
|
||||
<DatenAendernPanel
|
||||
open={panel === "daten"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
dependents={dependents}
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</div>
|
||||
|
||||
59
components/employees/SvNummerField.tsx
Normal file
59
components/employees/SvNummerField.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatSvnr, requiresAustrianSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
||||
|
||||
type SvNummerFieldProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Country of the employee's work location — validation applies to Austria only. */
|
||||
locationCountry: string | null | undefined;
|
||||
/** ISO yyyy-mm-dd; enables the cross-check against the TTMMJJ tail. */
|
||||
birthDate?: string | null;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared by the hire wizard and the "Daten ändern" panel so both apply the
|
||||
* same rule. Errors are shown only once the field has been left, so the
|
||||
* message does not flash while the ten digits are still being typed.
|
||||
*/
|
||||
export function SvNummerField({ value, onChange, locationCountry, birthDate, labelClassName }: SvNummerFieldProps) {
|
||||
const [touched, setTouched] = useState(false);
|
||||
const applies = requiresAustrianSvnr(locationCountry);
|
||||
const error = applies && value.trim() !== "" ? validateSvnr(value, birthDate) : null;
|
||||
const showError = touched && error !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="sv-nummer" className={labelClassName ?? "mb-1 block text-sm font-semibold text-ink"}>
|
||||
SV-Nummer
|
||||
</label>
|
||||
<input
|
||||
id="sv-nummer"
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
placeholder={applies ? "1237 010180" : undefined}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={() => {
|
||||
setTouched(true);
|
||||
// Normalise to the conventional "NNNN TTMMJJ" spacing once the
|
||||
// value is complete; anything else is left exactly as typed.
|
||||
const formatted = formatSvnr(value);
|
||||
if (formatted !== value) onChange(formatted);
|
||||
}}
|
||||
aria-invalid={showError || undefined}
|
||||
aria-describedby={showError ? "sv-nummer-error" : undefined}
|
||||
className={`w-full rounded border px-3 py-2 text-sm ${showError ? "border-danger-solid" : "border-border"}`}
|
||||
/>
|
||||
{showError && (
|
||||
<p id="sv-nummer-error" className="mt-1 text-xs font-semibold text-danger-text">
|
||||
{svnrErrorMessage(error)}
|
||||
</p>
|
||||
)}
|
||||
{applies && !showError && (
|
||||
<p className="mt-1 text-xs text-ink-muted">10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ).</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import { useState } from "react";
|
||||
import { changeEmployeeData } from "@/actions/employees";
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
|
||||
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||
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 { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
@@ -21,15 +23,19 @@ export function DatenAendernPanel({
|
||||
onClose,
|
||||
employee,
|
||||
dependents,
|
||||
locationCountry,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
dependents: Dependent[];
|
||||
locationCountry: string | null | undefined;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
// The DB trigger would reject a bad number anyway; catching it here keeps
|
||||
// the whole effective-dated change from being thrown away on submit.
|
||||
const [effectiveDate, setEffectiveDate] = useState(todayIso);
|
||||
|
||||
const [firstName, setFirstName] = useState(employee.first_name);
|
||||
@@ -41,6 +47,8 @@ export function DatenAendernPanel({
|
||||
const [gender, setGender] = useState<GenderType>(employee.gender);
|
||||
const [birthDate, setBirthDate] = useState(employee.birth_date);
|
||||
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
|
||||
const svNummerOk =
|
||||
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
|
||||
const [nationality, setNationality] = useState(employee.nationality);
|
||||
const [address, setAddress] = useState(employee.address ?? "");
|
||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||
@@ -148,7 +156,8 @@ export function DatenAendernPanel({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
disabled={pending || !svNummerOk}
|
||||
title={svNummerOk ? undefined : "Die SV-Nummer ist ungültig."}
|
||||
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
@@ -199,10 +208,13 @@ export function DatenAendernPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-ink-muted">SV-Nummer</label>
|
||||
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<SvNummerField
|
||||
value={svNummer}
|
||||
onChange={setSvNummer}
|
||||
locationCountry={locationCountry}
|
||||
birthDate={birthDate || null}
|
||||
labelClassName="mb-1 block text-xs font-semibold text-ink-muted"
|
||||
/>
|
||||
<div>
|
||||
<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…" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { StepPerson } from "./StepPerson";
|
||||
import { StepPosition } from "./StepPosition";
|
||||
import { StepSummary } from "./StepSummary";
|
||||
@@ -48,8 +49,15 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
setDraft((prev) => ({ ...prev, ...patch }));
|
||||
}
|
||||
|
||||
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
|
||||
// trigger rejects a bad number, and by then the user is three steps on.
|
||||
const svNummerOk =
|
||||
!draft.svNummer.trim() ||
|
||||
!requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) ||
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SvNummerField } from "@/components/employees/SvNummerField";
|
||||
import { TitleFields } from "@/components/employees/TitleFields";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -38,10 +39,12 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
<input type="date" value={draft.birthDate} onChange={(e) => update({ birthDate: 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">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>
|
||||
<SvNummerField
|
||||
value={draft.svNummer}
|
||||
onChange={(svNummer) => update({ svNummer })}
|
||||
locationCountry={locations.find((l) => l.id === draft.locationId)?.country}
|
||||
birthDate={draft.birthDate || null}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -8,7 +8,6 @@ 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 OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||
|
||||
@@ -21,7 +20,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
||||
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();
|
||||
|
||||
@@ -76,15 +74,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
||||
</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 })}
|
||||
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"
|
||||
>
|
||||
Besetzen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -93,15 +82,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
||||
</div>
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
||||
{staffTarget && (
|
||||
<StaffInternallyModal
|
||||
open={Boolean(staffTarget)}
|
||||
onClose={() => setStaffTarget(null)}
|
||||
positionId={staffTarget.id}
|
||||
positionTitle={staffTarget.title}
|
||||
isLead={staffTarget.is_lead}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { searchActiveEmployees, type EmployeeSearchResult } from "@/actions/employees";
|
||||
import { staffPositionInternally } from "@/actions/positions";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
|
||||
type StaffInternallyModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
positionId: string;
|
||||
positionTitle: string;
|
||||
isLead: boolean;
|
||||
};
|
||||
|
||||
export function StaffInternallyModal({ open, onClose, positionId, positionTitle, isLead }: StaffInternallyModalProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [employee, setEmployee] = useState<EmployeeSearchResult | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!employee) return;
|
||||
setPending(true);
|
||||
const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id });
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(`${employee.first_name} ${employee.last_name} besetzt die Position.`);
|
||||
router.refresh();
|
||||
onClose();
|
||||
setEmployee(null);
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Besetzen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Position besetzen"
|
||||
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 || !employee}
|
||||
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
Besetzen
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-ink-body">
|
||||
Position: <span className="font-semibold">{positionTitle}</span>
|
||||
</p>
|
||||
{isLead && (
|
||||
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||
Dies ist eine Führungsposition: Das gesamte Team wird der ausgewählten Person unterstellt.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Mitarbeiter:in*</label>
|
||||
{!employee ? (
|
||||
<Lookup<EmployeeSearchResult>
|
||||
placeholder="Name oder Titel…"
|
||||
onSearch={searchActiveEmployees}
|
||||
onSelect={setEmployee}
|
||||
renderResult={(e) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{employee.first_name} {employee.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{employee.job_title}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setEmployee(null)} className="text-xs text-ink-muted hover:text-ink">
|
||||
Ändern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -429,6 +429,7 @@ export type Database = {
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
|
||||
88
lib/svnr.ts
Normal file
88
lib/svnr.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Österreichische Sozialversicherungsnummer (SVNR).
|
||||
//
|
||||
// Ten digits: three-digit serial, one check digit, then the date of birth as
|
||||
// TTMMJJ — e.g. "1237 010180". The check digit is the weighted sum of the
|
||||
// other nine digits modulo 11; a serial whose sum yields 11 leaves no usable
|
||||
// digit and is simply never issued, which is why 10 has to be rejected
|
||||
// rather than wrapped.
|
||||
//
|
||||
// Only employees at an Austrian location have one. Everyone else keeps the
|
||||
// field free-form, since the German/Czech/Slovenian equivalents have their
|
||||
// own formats and are not what this validates.
|
||||
|
||||
const WEIGHTS = [3, 7, 9, 0, 5, 8, 4, 2, 1, 6] as const;
|
||||
const CHECK_INDEX = 3;
|
||||
|
||||
export type SvnrError = "length" | "serial" | "checksum" | "date" | "birthDateMismatch";
|
||||
|
||||
const MESSAGES: Record<SvnrError, string> = {
|
||||
length: "Die SV-Nummer muss aus 10 Ziffern bestehen (4 Ziffern, dann TTMMJJ).",
|
||||
serial: "Die laufende Nummer darf nicht 000 sein.",
|
||||
checksum: "Die Prüfziffer stimmt nicht. Bitte die Eingabe kontrollieren.",
|
||||
date: "Die letzten 6 Stellen ergeben kein gültiges Geburtsdatum (TTMMJJ).",
|
||||
birthDateMismatch: "Die SV-Nummer enthält ein anderes Geburtsdatum als im Stammdatensatz.",
|
||||
};
|
||||
|
||||
export function svnrErrorMessage(error: SvnrError): string {
|
||||
return MESSAGES[error];
|
||||
}
|
||||
|
||||
/** Strips spaces and separators; keeps everything else so bad input still fails loudly. */
|
||||
export function normalizeSvnr(input: string): string {
|
||||
return input.replace(/[\s./-]/g, "");
|
||||
}
|
||||
|
||||
/** "1237010180" → "1237 010180"; leaves anything non-canonical untouched. */
|
||||
export function formatSvnr(input: string): string {
|
||||
const n = normalizeSvnr(input);
|
||||
return /^\d{10}$/.test(n) ? `${n.slice(0, 4)} ${n.slice(4)}` : input;
|
||||
}
|
||||
|
||||
export function svnrCheckDigit(digits: string): number | null {
|
||||
const n = normalizeSvnr(digits);
|
||||
if (!/^\d{10}$/.test(n)) return null;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i === CHECK_INDEX) continue;
|
||||
sum += Number(n[i]) * WEIGHTS[i];
|
||||
}
|
||||
const check = sum % 11;
|
||||
return check === 10 ? null : check;
|
||||
}
|
||||
|
||||
/**
|
||||
* `birthDate` (ISO yyyy-mm-dd) is optional; when given, the TTMMJJ tail is
|
||||
* cross-checked against it — the single most common data-entry slip is a
|
||||
* transposed birth date, which the checksum alone will not catch.
|
||||
*/
|
||||
export function validateSvnr(input: string, birthDate?: string | null): SvnrError | null {
|
||||
const n = normalizeSvnr(input);
|
||||
if (!/^\d{10}$/.test(n)) return "length";
|
||||
if (n.slice(0, 3) === "000") return "serial";
|
||||
|
||||
const day = Number(n.slice(4, 6));
|
||||
const month = Number(n.slice(6, 8));
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31) return "date";
|
||||
// Without a century the tail cannot be resolved to a real date, so the
|
||||
// day-of-month bound is the generous one; a supplied birthDate settles it.
|
||||
if (day > [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]) return "date";
|
||||
|
||||
const expected = svnrCheckDigit(n);
|
||||
if (expected === null || expected !== Number(n[CHECK_INDEX])) return "checksum";
|
||||
|
||||
if (birthDate) {
|
||||
const [y, m, d] = birthDate.split("-");
|
||||
if (d !== n.slice(4, 6) || m !== n.slice(6, 8) || y?.slice(-2) !== n.slice(8, 10)) return "birthDateMismatch";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isValidSvnr(input: string, birthDate?: string | null): boolean {
|
||||
return validateSvnr(input, birthDate) === null;
|
||||
}
|
||||
|
||||
/** Countries whose employees this validation applies to. */
|
||||
export function requiresAustrianSvnr(locationCountry: string | null | undefined): boolean {
|
||||
return locationCountry === "Österreich";
|
||||
}
|
||||
@@ -1,14 +1,47 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Report-only rather than enforcing, deliberately: the policy is derived from
|
||||
// what this app is known to load — its own bundle, the self-hosted Nunito
|
||||
// files from next/font, and the Supabase project from
|
||||
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
|
||||
// worth more than a guessed one that blanks the app for every HR user.
|
||||
// Promote it to `Content-Security-Policy` once the reports come back clean.
|
||||
//
|
||||
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
||||
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
||||
// policy means threading a per-request nonce through proxy.ts — a separate
|
||||
// change, and the reason this starts in report-only.
|
||||
function contentSecurityPolicy(): string {
|
||||
let supabaseOrigin = "";
|
||||
try {
|
||||
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
|
||||
} catch {
|
||||
supabaseOrigin = "";
|
||||
}
|
||||
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
|
||||
// against the same host.
|
||||
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
`connect-src ${connect}`,
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Emits a self-contained .next/standalone server (only the deps actually
|
||||
// used at runtime, no full node_modules) - what the Dockerfile copies in.
|
||||
output: "standalone",
|
||||
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
|
||||
// browser feature access). No Content-Security-Policy yet: this app has no
|
||||
// inventory of its script/style/connect sources, and shipping a guessed
|
||||
// CSP risks silently breaking Next.js hydration or the Supabase client —
|
||||
// TODO revisit once the actual source list is audited.
|
||||
// browser feature access) plus the report-only CSP described above.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -19,6 +52,7 @@ const nextConfig: NextConfig = {
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
|
||||
{ key: "Content-Security-Policy-Report-Only", value: contentSecurityPolicy() },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
1119
package-lock.json
generated
1119
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -11,32 +11,40 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "node --env-file=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"types:generate": "supabase gen types typescript --local > lib/supabase/types.generated.ts",
|
||||
"types:check": "node scripts/check-schema-types.mjs",
|
||||
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22 <25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@supabase/ssr": "^0.12.1",
|
||||
"@supabase/supabase-js": "^2.110.5",
|
||||
"@supabase/ssr": "^0.12.3",
|
||||
"@supabase/supabase-js": "^2.110.8",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "^16.2.11",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"server-only": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"eslint-config-next": "^16.2.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.19",
|
||||
"supabase": "^2.109.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
|
||||
158
scripts/check-schema-types.mjs
Normal file
158
scripts/check-schema-types.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
// Guards lib/supabase/types.ts against drifting away from the migrations.
|
||||
//
|
||||
// That file is maintained by hand (its own header explains why: no DB
|
||||
// connection string is available to run `supabase gen types`), so a new
|
||||
// column reaches the database without the TypeScript side noticing — and
|
||||
// `tsc` stays perfectly happy while the app reads a field that is typed but
|
||||
// absent, or writes one that exists but is not typed.
|
||||
//
|
||||
// Reads the migrations rather than a live database on purpose: CI then needs
|
||||
// no Postgres, and the migrations are the actual source of truth for what
|
||||
// gets deployed.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const migrationsDir = join(root, "supabase", "migrations");
|
||||
|
||||
// Columns are compared, not their types: the hand-written file deliberately
|
||||
// uses richer unions than the SQL (`EmploymentStatus` for a text column with
|
||||
// a check constraint), and flagging those would be noise.
|
||||
const NON_COLUMN_START =
|
||||
/^(constraint|check|primary|unique|foreign|exclude|like|inherits|partition|)$/i;
|
||||
|
||||
function stripComments(sql) {
|
||||
return sql.replace(/--[^\n]*/g, "");
|
||||
}
|
||||
|
||||
/** Splits on top-level commas only, so `numeric(10,2)` stays in one piece. */
|
||||
function splitTopLevel(body) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const ch of body) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth--;
|
||||
if (ch === "," && depth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.trim()) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseMigrations() {
|
||||
const tables = new Map();
|
||||
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
||||
|
||||
for (const file of files) {
|
||||
const sql = stripComments(readFileSync(join(migrationsDir, file), "utf8"));
|
||||
|
||||
for (const match of sql.matchAll(/create table (?:if not exists )?(\w+)\s*\(/gi)) {
|
||||
const name = match[1];
|
||||
// Walk from the opening paren to its match so nested parens in column
|
||||
// definitions do not end the table early.
|
||||
let depth = 0;
|
||||
let end = match.index + match[0].length - 1;
|
||||
for (let i = end; i < sql.length; i++) {
|
||||
if (sql[i] === "(") depth++;
|
||||
else if (sql[i] === ")") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const body = sql.slice(match.index + match[0].length, end);
|
||||
const columns = new Set();
|
||||
for (const part of splitTopLevel(body)) {
|
||||
const first = part.trim().split(/\s+/)[0] ?? "";
|
||||
if (!first || NON_COLUMN_START.test(first)) continue;
|
||||
columns.add(first.toLowerCase());
|
||||
}
|
||||
tables.set(name, columns);
|
||||
}
|
||||
|
||||
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+add column (?:if not exists )?(\w+)/gi)) {
|
||||
tables.get(m[1])?.add(m[2].toLowerCase());
|
||||
}
|
||||
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+drop column (?:if exists )?(\w+)/gi)) {
|
||||
tables.get(m[1])?.delete(m[2].toLowerCase());
|
||||
}
|
||||
for (const m of sql.matchAll(/alter table (?:if exists )?(\w+)\s+rename column (\w+) to (\w+)/gi)) {
|
||||
const t = tables.get(m[1]);
|
||||
if (t?.delete(m[2].toLowerCase())) t.add(m[3].toLowerCase());
|
||||
}
|
||||
for (const m of sql.matchAll(/drop table (?:if exists )?(\w+)/gi)) {
|
||||
tables.delete(m[1]);
|
||||
}
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
function parseTypes() {
|
||||
const source = readFileSync(join(root, "lib", "supabase", "types.ts"), "utf8");
|
||||
const start = source.indexOf("Tables: {");
|
||||
if (start === -1) throw new Error("Tables block not found in lib/supabase/types.ts");
|
||||
|
||||
const tables = new Map();
|
||||
// Each entry looks like `name: NoRelationships & { Row: { … } … }`; the Row
|
||||
// block is the one that has to match the database.
|
||||
const entry = /^ {6}(\w+): /gm;
|
||||
for (const m of source.slice(start).matchAll(entry)) {
|
||||
const rest = source.slice(start + m.index);
|
||||
const rowAt = rest.indexOf("Row: {");
|
||||
if (rowAt === -1) continue;
|
||||
let depth = 0;
|
||||
let end = rowAt + "Row: ".length;
|
||||
for (let i = end; i < rest.length; i++) {
|
||||
if (rest[i] === "{") depth++;
|
||||
else if (rest[i] === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const body = rest.slice(rowAt + "Row: {".length, end);
|
||||
const columns = new Set();
|
||||
for (const f of body.matchAll(/(\w+)\s*\??\s*:/g)) columns.add(f[1].toLowerCase());
|
||||
tables.set(m[1], columns);
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
const sqlTables = parseMigrations();
|
||||
const tsTables = parseTypes();
|
||||
const problems = [];
|
||||
|
||||
for (const [name, columns] of tsTables) {
|
||||
const sqlColumns = sqlTables.get(name);
|
||||
if (!sqlColumns) {
|
||||
problems.push(`Tabelle "${name}" ist in types.ts typisiert, existiert aber in keiner Migration.`);
|
||||
continue;
|
||||
}
|
||||
for (const c of columns) {
|
||||
if (!sqlColumns.has(c)) problems.push(`${name}.${c}: in types.ts typisiert, in den Migrationen nicht vorhanden.`);
|
||||
}
|
||||
for (const c of sqlColumns) {
|
||||
if (!columns.has(c)) problems.push(`${name}.${c}: in den Migrationen vorhanden, in types.ts nicht typisiert.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error("Schema-Drift zwischen supabase/migrations und lib/supabase/types.ts:\n");
|
||||
for (const p of problems.sort()) console.error(" - " + p);
|
||||
console.error(`\n${problems.length} Abweichung(en). types.ts entsprechend nachziehen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Kein Schema-Drift: ${tsTables.size} typisierte Tabellen stimmen mit den Migrationen überein.`);
|
||||
99
supabase/migrations/20260725120000_svnr_validation.sql
Normal file
99
supabase/migrations/20260725120000_svnr_validation.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Validation of the Austrian Sozialversicherungsnummer (SVNR).
|
||||
--
|
||||
-- `sv_nummer` has been free text since the initial schema. For employees at
|
||||
-- an Austrian location it follows a fixed standard — three-digit serial,
|
||||
-- check digit, then TTMMJJ — and the check digit is verifiable, so typos
|
||||
-- that would otherwise surface at the payroll interface can be caught on
|
||||
-- entry. Employees at the German/Czech/Slovenian locations keep the field
|
||||
-- free-form; their national equivalents have different formats.
|
||||
--
|
||||
-- Enforced by a trigger rather than inside hire_employee/change_employee_data
|
||||
-- for the same reason as the assignment history: both functions have been
|
||||
-- redefined by half a dozen migrations, and a check in the table catches
|
||||
-- every write path including ones added later.
|
||||
|
||||
create or replace function is_valid_svnr(p_svnr text, p_birth_date date default null)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
immutable
|
||||
as $$
|
||||
declare
|
||||
v text := regexp_replace(coalesce(p_svnr, ''), '[\s./-]', '', 'g');
|
||||
v_weights int[] := array[3, 7, 9, 0, 5, 8, 4, 2, 1, 6];
|
||||
v_days_in_month int[] := array[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
v_sum int := 0;
|
||||
v_day int;
|
||||
v_month int;
|
||||
v_check int;
|
||||
begin
|
||||
if v !~ '^\d{10}$' then return false; end if;
|
||||
-- 000 is never issued as a serial.
|
||||
if substr(v, 1, 3) = '000' then return false; end if;
|
||||
|
||||
v_day := substr(v, 5, 2)::int;
|
||||
v_month := substr(v, 7, 2)::int;
|
||||
if v_month < 1 or v_month > 12 or v_day < 1 then return false; end if;
|
||||
-- February is allowed 29 days: a two-digit year cannot tell us whether the
|
||||
-- year was a leap year, so the generous bound is the correct one here.
|
||||
if v_day > v_days_in_month[v_month] then return false; end if;
|
||||
|
||||
for i in 1..10 loop
|
||||
if i <> 4 then
|
||||
v_sum := v_sum + substr(v, i, 1)::int * v_weights[i];
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
v_check := v_sum % 11;
|
||||
-- A serial whose weighted sum lands on 11 leaves no single digit to use,
|
||||
-- so that serial is skipped rather than wrapped around.
|
||||
if v_check = 10 then return false; end if;
|
||||
if v_check <> substr(v, 4, 1)::int then return false; end if;
|
||||
|
||||
if p_birth_date is not null and to_char(p_birth_date, 'DDMMYY') <> substr(v, 5, 6) then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
return true;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function is_valid_svnr(text, date) is
|
||||
'Prüft eine österreichische SV-Nummer (10 Ziffern, Prüfziffer mod 11). Mit p_birth_date wird zusätzlich der TTMMJJ-Teil gegen das Geburtsdatum geprüft.';
|
||||
|
||||
create or replace function fn_validate_employee_svnr()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_country text;
|
||||
begin
|
||||
if new.sv_nummer is null or btrim(new.sv_nummer) = '' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- Only a *newly written* value is checked. Rows that predate this
|
||||
-- migration keep whatever they hold, so an unrelated edit — a transfer, a
|
||||
-- promotion, an address change — is never blocked by a legacy value the
|
||||
-- user is not touching.
|
||||
if tg_op = 'UPDATE' and new.sv_nummer is not distinct from old.sv_nummer then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select country into v_country from locations where id = new.location_id;
|
||||
if v_country is distinct from 'Österreich' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
if not is_valid_svnr(new.sv_nummer, new.birth_date) then
|
||||
raise exception 'Ungültige SV-Nummer: %. Erwartet werden 10 Ziffern (laufende Nummer, Prüfziffer, TTMMJJ) mit gültiger Prüfziffer und dem Geburtsdatum des/der Mitarbeiter:in.', new.sv_nummer
|
||||
using errcode = '23514';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_validate_employee_svnr on employees;
|
||||
create trigger trg_validate_employee_svnr
|
||||
before insert or update on employees
|
||||
for each row execute function fn_validate_employee_svnr();
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
// Explicit .ts extension: this file is run directly by Node (type-stripping,
|
||||
// ESM), where an extensionless relative import does not resolve.
|
||||
import { svnrCheckDigit } from "../lib/svnr.ts";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
@@ -365,12 +368,22 @@ function makeEmail(firstName: string, lastName: string): string {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Used a random four-digit prefix before, so the check digit was right only
|
||||
// by chance — which the SVNR validation trigger now rejects outright. The
|
||||
// serial is still random; only the check digit is computed for it.
|
||||
function makeSvNummer(birthDate: Date): string {
|
||||
const dd = String(birthDate.getDate()).padStart(2, "0");
|
||||
const mm = String(birthDate.getMonth() + 1).padStart(2, "0");
|
||||
const yy = String(birthDate.getFullYear()).slice(-2);
|
||||
const prefix = String(randInt(1000, 9999));
|
||||
return `${prefix} ${dd}${mm}${yy}`;
|
||||
const tail = `${dd}${mm}${yy}`;
|
||||
|
||||
// Not every serial yields a usable check digit (a weighted sum of 11 is
|
||||
// skipped rather than wrapped), so draw until one does.
|
||||
for (;;) {
|
||||
const serial = String(randInt(1, 999)).padStart(3, "0");
|
||||
const check = svnrCheckDigit(`${serial}0${tail}`);
|
||||
if (check !== null) return `${serial}${check} ${tail}`;
|
||||
}
|
||||
}
|
||||
|
||||
function birthDateForAge(age: number): Date {
|
||||
|
||||
98
tests/components/GraphOrgChart.test.tsx
Normal file
98
tests/components/GraphOrgChart.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useCallback, useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GraphOrgChart } from "@/components/orgchart/GraphOrgChart";
|
||||
import type { ChartNode } from "@/components/orgchart/types";
|
||||
|
||||
// Regression cover for the graphical org chart. The expand control lives
|
||||
// inside a React Flow custom node, and React Flow decides whether that node
|
||||
// receives pointer events at all from the props passed to <ReactFlow>:
|
||||
// setting elementsSelectable={false} alongside nodesDraggable={false} made it
|
||||
// compute pointer-events:none for the whole card, so neither the toggle nor
|
||||
// the employee link responded. Nothing in the type system catches that, and
|
||||
// no logic test would either — it only shows up when the tree is rendered
|
||||
// and actually clicked.
|
||||
|
||||
function person(id: string, name: string, children: ChartNode[] = []): ChartNode {
|
||||
return {
|
||||
id,
|
||||
kind: "person",
|
||||
label: name,
|
||||
sublabel: "Testrolle",
|
||||
href: `/employees/${id}`,
|
||||
avatar: { firstName: name.split(" ")[0], lastName: name.split(" ")[1] ?? "X" },
|
||||
totalReports: children.length,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
const TREE: ChartNode[] = [
|
||||
person("1", "Johanna Wiesinger", [
|
||||
person("2", "Markus Steinbacher", [person("4", "Katharina Aigner"), person("5", "Kurt Aigner")]),
|
||||
person("3", "Simon Aigner"),
|
||||
]),
|
||||
];
|
||||
|
||||
function Harness({ initial = ["1"] }: { initial?: string[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(initial));
|
||||
const onToggle = 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]);
|
||||
return <GraphOrgChart tree={TREE} isExpanded={isExpanded} onToggle={onToggle} matchedIds={null} />;
|
||||
}
|
||||
|
||||
describe("GraphOrgChart", () => {
|
||||
it("renders the root and its expanded children", async () => {
|
||||
render(<Harness />);
|
||||
expect(await screen.findByText("Johanna Wiesinger")).toBeInTheDocument();
|
||||
expect(screen.getByText("Markus Steinbacher")).toBeInTheDocument();
|
||||
expect(screen.getByText("Simon Aigner")).toBeInTheDocument();
|
||||
// Grandchildren stay hidden until their parent is expanded.
|
||||
expect(screen.queryByText("Katharina Aigner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands a collapsed branch when its control is clicked", async () => {
|
||||
render(<Harness />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Markus Steinbacher aufklappen/i }));
|
||||
expect(await screen.findByText("Katharina Aigner")).toBeInTheDocument();
|
||||
expect(screen.getByText("Kurt Aigner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses again", async () => {
|
||||
render(<Harness initial={["1", "2"]} />);
|
||||
expect(await screen.findByText("Katharina Aigner")).toBeInTheDocument();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Johanna Wiesinger zuklappen/i }));
|
||||
expect(screen.queryByText("Markus Steinbacher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the node's pointer events enabled", () => {
|
||||
// The direct assertion on the regression: React Flow writes
|
||||
// pointer-events onto the node wrapper as an inline style, and 'none'
|
||||
// there silently disables everything inside the card.
|
||||
const { container } = render(<Harness />);
|
||||
const nodes = container.querySelectorAll<HTMLElement>(".react-flow__node");
|
||||
expect(nodes.length).toBeGreaterThan(0);
|
||||
for (const node of nodes) {
|
||||
expect(node.style.pointerEvents).not.toBe("none");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the child count on a collapsed node and links to the employee", async () => {
|
||||
render(<Harness />);
|
||||
expect(await screen.findByRole("button", { name: /Markus Steinbacher aufklappen \(2\)/i })).toHaveTextContent("2");
|
||||
expect(screen.getByRole("link", { name: /Johanna Wiesinger/i })).toHaveAttribute("href", "/employees/1");
|
||||
});
|
||||
|
||||
it("gives a leaf no expand control", async () => {
|
||||
render(<Harness />);
|
||||
await screen.findByText("Simon Aigner");
|
||||
expect(screen.queryByRole("button", { name: /Simon Aigner (auf|zu)klappen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
39
tests/components/setup.ts
Normal file
39
tests/components/setup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// React Flow keeps a node at visibility:hidden until ResizeObserver has
|
||||
// reported its size, and Testing Library excludes hidden elements from the
|
||||
// accessibility tree — so an inert stub makes every node unqueryable by
|
||||
// role. This one reports a size, which is what actually makes the nodes
|
||||
// visible and the chart behave as it does in a browser.
|
||||
const NODE_WIDTH = 268;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
class ResizeObserverStub {
|
||||
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
const entry = {
|
||||
target,
|
||||
contentRect: { width: NODE_WIDTH, height: NODE_HEIGHT, top: 0, left: 0, bottom: NODE_HEIGHT, right: NODE_WIDTH, x: 0, y: 0 },
|
||||
} as unknown as ResizeObserverEntry;
|
||||
queueMicrotask(() => this.callback([entry], this as unknown as ResizeObserver));
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
|
||||
|
||||
if (!("DOMMatrixReadOnly" in globalThis)) {
|
||||
class DOMMatrixReadOnlyStub {
|
||||
m22 = 1;
|
||||
}
|
||||
vi.stubGlobal("DOMMatrixReadOnly", DOMMatrixReadOnlyStub);
|
||||
}
|
||||
|
||||
// jsdom reports 0 for every layout box; React Flow reads these to size nodes.
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, value: NODE_WIDTH });
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, value: NODE_HEIGHT });
|
||||
Object.defineProperty(SVGElement.prototype, "getBBox", { configurable: true, value: () => ({ x: 0, y: 0, width: 0, height: 0 }) });
|
||||
122
tests/integration/svnr-validation.test.ts
Normal file
122
tests/integration/svnr-validation.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTestEmployee, pickSeededTeam, signInAs, type TestUser } from "./helpers";
|
||||
|
||||
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
|
||||
// Enforced by a trigger, so these drive it through the real write paths and
|
||||
// through a direct update — both must be covered by the same rule.
|
||||
describe("SVNR validation", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let team: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
// 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7
|
||||
const VALID = "1237 010180";
|
||||
const BIRTH_DATE = "1980-01-01";
|
||||
|
||||
async function locationIn(country: string): Promise<string> {
|
||||
const { data } = await adminClient.from("locations").select("id").eq("country", country).limit(1).maybeSingle();
|
||||
if (!data) throw new Error(`no seeded location in ${country}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
team = await pickSeededTeam();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, team.id, {
|
||||
location_id: await locationIn(country),
|
||||
birth_date: BIRTH_DATE,
|
||||
...overrides,
|
||||
});
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("is_valid_svnr", () => {
|
||||
async function check(svnr: string, birthDate: string | null = null): Promise<boolean> {
|
||||
const { data, error } = await adminClient.rpc("is_valid_svnr", { p_svnr: svnr, p_birth_date: birthDate });
|
||||
if (error) throw new Error(error.message);
|
||||
return data as unknown as boolean;
|
||||
}
|
||||
|
||||
it("matches the TypeScript implementation on the documented cases", async () => {
|
||||
expect(await check(VALID)).toBe(true);
|
||||
expect(await check("1237010180")).toBe(true);
|
||||
expect(await check("1234 010180")).toBe(false); // wrong check digit
|
||||
expect(await check("0007 010180")).toBe(false); // 000 serial
|
||||
expect(await check("0040 010180")).toBe(false); // check digit would be 10
|
||||
expect(await check("1237 011380")).toBe(false); // month 13
|
||||
expect(await check("123701018")).toBe(false); // nine digits
|
||||
});
|
||||
|
||||
it("cross-checks the birth date when one is given", async () => {
|
||||
expect(await check(VALID, BIRTH_DATE)).toBe(true);
|
||||
expect(await check(VALID, "1980-01-02")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("at an Austrian location", () => {
|
||||
it("accepts a hire carrying a valid number", async () => {
|
||||
const id = await hireAt("Österreich", { sv_nummer: VALID });
|
||||
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
||||
expect(data?.sv_nummer).toBe(VALID);
|
||||
});
|
||||
|
||||
it("rejects a hire carrying an invalid number", async () => {
|
||||
await expect(hireAt("Österreich", { sv_nummer: "1234 010180" })).rejects.toThrow(/SV-Nummer/i);
|
||||
});
|
||||
|
||||
it("rejects a number whose birth date disagrees with the employee record", async () => {
|
||||
await expect(hireAt("Österreich", { sv_nummer: VALID, birth_date: "1975-06-30" })).rejects.toThrow(/SV-Nummer/i);
|
||||
});
|
||||
|
||||
it("allows the field to stay empty", async () => {
|
||||
const id = await hireAt("Österreich");
|
||||
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
||||
expect(data?.sv_nummer ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an update to an invalid number", async () => {
|
||||
const id = await hireAt("Österreich", { sv_nummer: VALID });
|
||||
const { error } = await adminClient.from("employees").update({ sv_nummer: "9999 010180" }).eq("id", id);
|
||||
expect(error?.message ?? "").toMatch(/SV-Nummer/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outside Austria", () => {
|
||||
it("leaves the field free-form", async () => {
|
||||
// The German equivalent has a different format entirely; validating it
|
||||
// against the Austrian standard would reject correct data.
|
||||
const id = await hireAt("Deutschland", { sv_nummer: "12 345678 A 901" });
|
||||
const { data } = await adminClient.from("employees").select("sv_nummer").eq("id", id).single();
|
||||
expect(data?.sv_nummer).toBe("12 345678 A 901");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy rows", () => {
|
||||
it("does not block an unrelated edit when the stored number is invalid", async () => {
|
||||
// Rows predating the migration hold unvalidated values. A transfer or
|
||||
// a name change must not fail because of a number nobody is touching.
|
||||
const id = await hireAt("Deutschland", { sv_nummer: "0000 000000" });
|
||||
const { error: moveError } = await adminClient
|
||||
.from("employees")
|
||||
.update({ location_id: await locationIn("Österreich") })
|
||||
.eq("id", id);
|
||||
expect(moveError).toBeNull();
|
||||
|
||||
const { error: nameError } = await adminClient.from("employees").update({ phone: "+43 1 9999999" }).eq("id", id);
|
||||
expect(nameError).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
111
tests/unit/svnr.test.ts
Normal file
111
tests/unit/svnr.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSvnr, isValidSvnr, normalizeSvnr, requiresAustrianSvnr, svnrCheckDigit, validateSvnr } from "@/lib/svnr";
|
||||
|
||||
// Weights 3,7,9 on the serial and 5,8,4,2,1,6 on TTMMJJ; sum mod 11 is the
|
||||
// check digit. Worked through by hand for "123? 010180":
|
||||
// 3·1 + 7·2 + 9·3 = 44 (serial)
|
||||
// 5·0 + 8·1 + 4·0 + 2·1 + 1·8 + 6·0 = 18 (010180)
|
||||
// 62 mod 11 = 7 → 1237 010180
|
||||
const VALID = "1237 010180";
|
||||
|
||||
describe("svnrCheckDigit", () => {
|
||||
it("computes the documented check digit", () => {
|
||||
expect(svnrCheckDigit("1230010180")).toBe(7);
|
||||
});
|
||||
|
||||
it("computes it independently of the digit already in that slot", () => {
|
||||
// The check position is skipped, so a wrong digit there cannot influence
|
||||
// the result — otherwise the check would validate itself.
|
||||
expect(svnrCheckDigit("1234010180")).toBe(7);
|
||||
expect(svnrCheckDigit("1239010180")).toBe(7);
|
||||
});
|
||||
|
||||
it("computes 3 for serial 432 on the same date", () => {
|
||||
// 3·4 + 7·3 + 9·2 = 51; 51 + 18 = 69; 69 mod 11 = 3
|
||||
expect(svnrCheckDigit("4320010180")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns null for a serial that would need a check digit of 10", () => {
|
||||
// 9·4 = 36; 36 + 18 = 54; 54 mod 11 = 10 — never issued, since there is
|
||||
// no single digit for it.
|
||||
expect(svnrCheckDigit("0040010180")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSvnr", () => {
|
||||
it("accepts a well-formed number", () => {
|
||||
expect(validateSvnr(VALID)).toBeNull();
|
||||
expect(isValidSvnr(VALID)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts it regardless of spacing or separators", () => {
|
||||
for (const variant of ["1237010180", "1237 010180", "1237-010180", "1237.010180", " 1237 010180 "]) {
|
||||
expect(validateSvnr(variant), variant).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a wrong check digit", () => {
|
||||
expect(validateSvnr("1234 010180")).toBe("checksum");
|
||||
});
|
||||
|
||||
it("rejects a serial whose check digit would be 10", () => {
|
||||
expect(validateSvnr("0040 010180")).toBe("checksum");
|
||||
});
|
||||
|
||||
it("rejects anything that is not ten digits", () => {
|
||||
for (const bad of ["", "123701018", "12370101801", "1237 01018X", "abcdefghij"]) {
|
||||
expect(validateSvnr(bad), bad).toBe("length");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects the unissued 000 serial", () => {
|
||||
expect(validateSvnr("0007 010180")).toBe("serial");
|
||||
});
|
||||
|
||||
it("rejects an impossible date tail", () => {
|
||||
// Month 13 and day 32 never occur; the checksum is irrelevant once the
|
||||
// tail cannot be a date at all.
|
||||
expect(validateSvnr("1237 011380")).toBe("date");
|
||||
expect(validateSvnr("1237 320180")).toBe("date");
|
||||
expect(validateSvnr("1237 310280")).toBe("date");
|
||||
});
|
||||
|
||||
it("allows 29 February, which a two-digit year cannot disambiguate", () => {
|
||||
// 3·1+7·2+9·3 = 44; 5·2+8·9+4·0+2·2+1·8+6·0 = 10+72+0+4+8 = 94; 138 mod 11 = 6
|
||||
expect(validateSvnr("1236 290280")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-check against the stored birth date", () => {
|
||||
it("passes when the tail matches", () => {
|
||||
expect(validateSvnr(VALID, "1980-01-01")).toBeNull();
|
||||
});
|
||||
|
||||
it("catches a transposed birth date the checksum cannot", () => {
|
||||
expect(validateSvnr(VALID, "1980-01-02")).toBe("birthDateMismatch");
|
||||
expect(validateSvnr(VALID, "1981-01-01")).toBe("birthDateMismatch");
|
||||
});
|
||||
|
||||
it("skips the cross-check when no birth date is known", () => {
|
||||
expect(validateSvnr(VALID, null)).toBeNull();
|
||||
expect(validateSvnr(VALID, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("normalises and formats", () => {
|
||||
expect(normalizeSvnr("1237 010180")).toBe("1237010180");
|
||||
expect(formatSvnr("1237010180")).toBe("1237 010180");
|
||||
});
|
||||
|
||||
it("leaves non-canonical input alone when formatting", () => {
|
||||
expect(formatSvnr("12")).toBe("12");
|
||||
});
|
||||
|
||||
it("applies only to Austrian locations", () => {
|
||||
expect(requiresAustrianSvnr("Österreich")).toBe(true);
|
||||
for (const c of ["Deutschland", "Tschechien", "Slowenien", null, undefined]) {
|
||||
expect(requiresAustrianSvnr(c)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -9,6 +9,9 @@
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
// supabase/seed.ts is executed directly by Node, which resolves relative
|
||||
// imports as ESM and therefore needs the explicit .ts extension.
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const alias = { "@": path.resolve(__dirname, ".") };
|
||||
|
||||
// Two projects in one run: pure logic keeps the node environment, component
|
||||
// tests get jsdom. Splitting them keeps the ~100 logic tests from paying for
|
||||
// a DOM they never touch.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
resolve: { alias },
|
||||
test: { name: "unit", environment: "node", include: ["tests/unit/**/*.test.ts"] },
|
||||
},
|
||||
{
|
||||
resolve: { alias },
|
||||
test: {
|
||||
name: "components",
|
||||
environment: "jsdom",
|
||||
setupFiles: ["tests/components/setup.ts"],
|
||||
include: ["tests/components/**/*.test.tsx"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user