diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a09cd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 0a597be..e519ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/actions/positions.ts b/actions/positions.ts index f5adc78..d06b6ff 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -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, revalidate: string[] ): Promise { @@ -34,13 +34,6 @@ export async function deletePosition(positionId: string): Promise return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); } -export async function staffPositionInternally(payload: { - position_id: string; - employee_id: string; -}): Promise { - 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 diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 19af0f3..db3d575 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -151,7 +151,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) { /> setPanel(null)} employee={employee} /> setPanel(null)} employee={employee} /> - setPanel(null)} employee={employee} dependents={dependents} /> + setPanel(null)} + employee={employee} + dependents={dependents} + locationCountry={location?.country} + /> setPanel(null)} employee={employee} directReportCount={directReports.length} /> setPanel(null)} employee={employee} /> diff --git a/components/employees/SvNummerField.tsx b/components/employees/SvNummerField.tsx new file mode 100644 index 0000000..0da2012 --- /dev/null +++ b/components/employees/SvNummerField.tsx @@ -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 ( +
+ + 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 && ( +

+ {svnrErrorMessage(error)} +

+ )} + {applies && !showError && ( +

10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ).

+ )} +
+ ); +} diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 6663f68..6bcc7f8 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -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(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({ ); })} @@ -93,15 +82,6 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien setCreateOpen(false)} teams={teams} /> - {staffTarget && ( - setStaffTarget(null)} - positionId={staffTarget.id} - positionTitle={staffTarget.title} - isLead={staffTarget.is_lead} - /> - )} ); } diff --git a/components/positions/StaffInternallyModal.tsx b/components/positions/StaffInternallyModal.tsx deleted file mode 100644 index 30f6a4d..0000000 --- a/components/positions/StaffInternallyModal.tsx +++ /dev/null @@ -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(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 ( - - - - - } - > -
-

- Position: {positionTitle} -

- {isLead && ( -
- Dies ist eine Führungsposition: Das gesamte Team wird der ausgewählten Person unterstellt. -
- )} -
- - {!employee ? ( - - placeholder="Name oder Titel…" - onSearch={searchActiveEmployees} - onSelect={setEmployee} - renderResult={(e) => ( -
-
- {e.first_name} {e.last_name} -
-
{e.job_title}
-
- )} - /> - ) : ( -
-
-
- {employee.first_name} {employee.last_name} -
-
{employee.job_title}
-
- -
- )} -
-
-
- ); -} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index b3781f5..c8507e5 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -429,6 +429,7 @@ export type Database = { create_position: { Args: { payload: Record }; Returns: string }; delete_position: { Args: { payload: Record }; Returns: void }; staff_position_internally: { Args: { payload: Record }; Returns: void }; + is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; apply_reorg: { Args: { payload: Record }; Returns: string }; undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; diff --git a/lib/svnr.ts b/lib/svnr.ts new file mode 100644 index 0000000..19b1e04 --- /dev/null +++ b/lib/svnr.ts @@ -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 = { + 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"; +} diff --git a/next.config.ts b/next.config.ts index 1ac6319..17ee181 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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