Ask for a residence permit only where one is needed

Employees from outside the EU, the EEA and Switzerland need a residence
permit, and HR needs to know when it expires — about eighty people in
the current data, across Türkei, Serbien and Bosnien. Two columns, the
same shape as the dismissal protection: a flag and a date that only
means anything with it. The date is optional, because an open-ended
permit has none and a mandatory field would force an invented one.

The nationality coupling deliberately stays out of the database. Putting
it there would mean keeping the country list in two places — SQL and
lib/countries.ts, where the picker needs it anyway — so an EU accession
would become a migration instead of a line in a list. Worse, correcting
somebody's nationality would fail the constraint while the old permit
was still attached, which is exactly the moment someone is fixing a
mistake. The UI decides whether the fields appear, and clears them when
the nationality moves into the free-movement area.

So the list is the load-bearing part, and it is tested: 31 entries, all
of them values the picker can actually produce, no duplicates, no third
countries. A missing nationality reads as "no permit required" — an
unanswered question is a reason to record it, not to demand papers.

The permit shows on the Stammdaten tab only for the nationalities it
applies to. A line reading "Aufenthaltstitel: Nein" under an Austrian
citizenship would look like information rather than a question that does
not arise.

Filter by it and by when it expires — the question behind that being
"whose permit runs out next quarter" — plus columns in the export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:27:26 +02:00
parent 384bdb4fb3
commit d2f4a7aab7
11 changed files with 607 additions and 1 deletions

View File

@@ -197,3 +197,63 @@ export const UN_COUNTRIES: string[] = [
"Zentralafrikanische Republik",
"Zypern",
].sort((a, b) => a.localeCompare(b, "de"));
// Wer keinen Aufenthaltstitel braucht.
//
// EU-27, dazu Island, Liechtenstein und Norwegen (EWR) sowie die Schweiz.
// Für diese Staatsangehörigen gilt Freizügigkeit; ein Aufenthaltstitel ist
// weder nötig noch zu erfassen. Für alle übrigen ist er es, und dann will HR
// wissen, bis wann er läuft.
//
// Die Liste steht hier und nicht in der Datenbank: eine Prüfbedingung, die
// Staatsbürgerschaft und Titel koppelt, würde bei jeder Korrektur der
// Staatsbürgerschaft zuschlagen — und Beitritte müssten als Migration
// nachgezogen werden, statt als Zeile in einer Liste.
export const FREIZUEGIGKEIT: readonly string[] = [
// EU
"Belgien",
"Bulgarien",
"Dänemark",
"Deutschland",
"Estland",
"Finnland",
"Frankreich",
"Griechenland",
"Irland",
"Italien",
"Kroatien",
"Lettland",
"Litauen",
"Luxemburg",
"Malta",
"Niederlande",
"Österreich",
"Polen",
"Portugal",
"Rumänien",
"Schweden",
"Slowakei",
"Slowenien",
"Spanien",
"Tschechien",
"Ungarn",
"Zypern",
// EWR
"Island",
"Liechtenstein",
"Norwegen",
// Bilaterale Freizügigkeit
"Schweiz",
];
/**
* Braucht jemand mit dieser Staatsbürgerschaft einen Aufenthaltstitel?
*
* Bei leerer Angabe: nein. Eine fehlende Staatsbürgerschaft ist kein Anlass,
* jemandem einen Titel abzuverlangen — sie ist ein Anlass, sie zu erfassen.
*/
export function brauchtAufenthaltstitel(nationality: string | null | undefined): boolean {
const n = nationality?.trim();
if (!n) return false;
return !FREIZUEGIGKEIT.includes(n);
}

View File

@@ -77,6 +77,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
{ key: "clevel", label: "C-Level" },
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
{ key: "teilzeit", label: "In einer Teilzeitvariante" },
{ key: "aufenthaltstitel", label: "Aufenthaltstitel" },
{ key: "dependents", label: "Angehörige erfasst" },
] as const;
@@ -89,6 +90,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
{ key: "birth", label: "Geburtsdatum" },
{ key: "schutz", label: "Kündigungsschutz bis" },
{ key: "teilzeitEnde", label: "Teilzeit endet" },
{ key: "titelEnde", label: "Aufenthaltstitel läuft ab" },
] as const;
export type Criteria = {

View File

@@ -76,6 +76,7 @@ export function applyCriteria<O>(
// „In einer Teilzeitvariante" heisst: es steht eine drin, gleich welche.
if (j.teilzeit === "ja") q = q.where("teilzeit_art", "is not", null);
if (j.teilzeit === "nein") q = q.where("teilzeit_art", "is", null);
if (j.aufenthaltstitel) q = q.where("hat_aufenthaltstitel", "=", j.aufenthaltstitel === "ja");
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
@@ -89,6 +90,10 @@ export function applyCriteria<O>(
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
if (c.zeitraum.teilzeitEnde?.von) q = q.where("teilzeit_bis", ">=", c.zeitraum.teilzeitEnde.von);
if (c.zeitraum.teilzeitEnde?.bis) q = q.where("teilzeit_bis", "<=", c.zeitraum.teilzeitEnde.bis);
// Wer bis wann einen Titel hat — die Frage hinter „wessen Titel läuft im
// nächsten Quartal aus".
if (c.zeitraum.titelEnde?.von) q = q.where("aufenthaltstitel_bis", ">=", c.zeitraum.titelEnde.von);
if (c.zeitraum.titelEnde?.bis) q = q.where("aufenthaltstitel_bis", "<=", c.zeitraum.titelEnde.bis);
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);

View File

@@ -149,6 +149,10 @@ export type Database = {
birth_date: string;
sv_nummer: string | null;
nationality: string;
/** Aufenthaltstitel — nur ausserhalb von EU, EWR und Schweiz erhoben. */
hat_aufenthaltstitel: boolean;
/** Gültig bis, falls befristet. Nur mit dem Kennzeichen zusammen erlaubt. */
aufenthaltstitel_bis: string | null;
address: string | null;
postal_code: string | null;
city: string | null;
@@ -208,6 +212,8 @@ export type Database = {
birth_date: string;
sv_nummer?: string | null;
nationality?: string;
hat_aufenthaltstitel?: boolean;
aufenthaltstitel_bis?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;