feat: add NV Bühne non-renewal & compensation module (Phase 3.2)

Implement § 61 NV Bühne non-renewal deadline calculation with tiered
protection (standard 31.10., extended 31.07. for 15+ years, special
protection for over-55), tariff-based compensation calculation with
Gagenklassen and Dienstalterszulage, and Spielzeit seasonal logic
(1.8.–31.7. with Probenzeit). Includes DB schema (contracts,
compensationRules, nonRenewalDeadlines), migration, and three API
endpoints under /api/nv-buehne/.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (LegalAI)
2026-04-08 23:25:54 +00:00
parent a7245001ad
commit 3c16fdc30f
9 changed files with 1348 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
CREATE TYPE "public"."contract_status" AS ENUM('aktiv', 'gekuendigt', 'nichtverlaengert', 'ausgelaufen', 'ruhend');--> statement-breakpoint
CREATE TYPE "public"."compensation_component" AS ENUM('grundgage', 'ortszuschlag', 'kinderzuschlag', 'dienstalterszulage', 'funktionszulage', 'sonderzahlung', 'probenzuschlag');--> statement-breakpoint
CREATE TABLE "contracts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"case_id" uuid,
"artist_name" varchar(255) NOT NULL,
"theater_name" varchar(255) NOT NULL,
"fachgruppe_id" uuid,
"status" "contract_status" DEFAULT 'aktiv' NOT NULL,
"contract_start" date NOT NULL,
"contract_end" date NOT NULL,
"years_of_service" integer DEFAULT 0 NOT NULL,
"is_first_engagement" boolean DEFAULT false,
"is_over_55" boolean DEFAULT false,
"current_spielzeit" varchar(20),
"gagenklasse" varchar(50),
"monthly_gross_cents" integer,
"notes" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "compensation_rules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid,
"fachgruppe_id" uuid,
"component" "compensation_component" NOT NULL,
"gagenklasse" varchar(50),
"label" varchar(255) NOT NULL,
"amount_cents" integer NOT NULL,
"min_years_of_service" integer DEFAULT 0,
"max_years_of_service" integer,
"valid_from_spielzeit" varchar(20),
"valid_to_spielzeit" varchar(20),
"legal_basis" varchar(255),
"description" text,
"sort_order" integer DEFAULT 0,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "non_renewal_deadlines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"contract_id" uuid NOT NULL,
"spielzeit" varchar(20) NOT NULL,
"deadline_date" date NOT NULL,
"warning_date" date,
"warning_days_before" integer DEFAULT 30,
"notice_sent" boolean DEFAULT false,
"notice_sent_date" date,
"is_auto_renewed" boolean DEFAULT false,
"legal_basis" varchar(255) NOT NULL,
"calculation_basis" text,
"protection_category" varchar(100),
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_fachgruppe_id_nv_buehne_fachgruppen_id_fk" FOREIGN KEY ("fachgruppe_id") REFERENCES "public"."nv_buehne_fachgruppen"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compensation_rules" ADD CONSTRAINT "compensation_rules_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compensation_rules" ADD CONSTRAINT "compensation_rules_fachgruppe_id_nv_buehne_fachgruppen_id_fk" FOREIGN KEY ("fachgruppe_id") REFERENCES "public"."nv_buehne_fachgruppen"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "non_renewal_deadlines" ADD CONSTRAINT "non_renewal_deadlines_contract_id_contracts_id_fk" FOREIGN KEY ("contract_id") REFERENCES "public"."contracts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "contracts_tenant_idx" ON "contracts" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "contracts_case_idx" ON "contracts" USING btree ("case_id");--> statement-breakpoint
CREATE INDEX "contracts_fachgruppe_idx" ON "contracts" USING btree ("fachgruppe_id");--> statement-breakpoint
CREATE INDEX "contracts_status_idx" ON "contracts" USING btree ("status");--> statement-breakpoint
CREATE INDEX "contracts_end_idx" ON "contracts" USING btree ("contract_end");--> statement-breakpoint
CREATE INDEX "compensation_rules_tenant_idx" ON "compensation_rules" USING btree ("tenant_id");--> statement-breakpoint
CREATE INDEX "compensation_rules_fachgruppe_idx" ON "compensation_rules" USING btree ("fachgruppe_id");--> statement-breakpoint
CREATE INDEX "compensation_rules_component_idx" ON "compensation_rules" USING btree ("component");--> statement-breakpoint
CREATE INDEX "compensation_rules_gagenklasse_idx" ON "compensation_rules" USING btree ("gagenklasse");--> statement-breakpoint
CREATE INDEX "non_renewal_deadlines_contract_idx" ON "non_renewal_deadlines" USING btree ("contract_id");--> statement-breakpoint
CREATE INDEX "non_renewal_deadlines_date_idx" ON "non_renewal_deadlines" USING btree ("deadline_date");--> statement-breakpoint
CREATE INDEX "non_renewal_deadlines_spielzeit_idx" ON "non_renewal_deadlines" USING btree ("spielzeit");

View File

@@ -0,0 +1,127 @@
// GET /api/nv-buehne/compensation — calculate compensation for given parameters
// POST /api/nv-buehne/compensation — calculate with full contract data from body
import {
calculateDefaultCompensation,
calculateCompensationFromRules,
getGagenklassen,
getSpielzeitForDate,
} from "@/lib/nv-buehne";
import { db } from "@/lib/db";
import { compensationRules } from "@/lib/db/schema";
import { eq, and, isNull, type SQL } from "drizzle-orm";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const gagenklasse = url.searchParams.get("gagenklasse");
const yearsOfService = parseInt(url.searchParams.get("yearsOfService") || "0", 10);
const fachgruppe = url.searchParams.get("fachgruppe") || null;
const spielzeit =
url.searchParams.get("spielzeit") ||
getSpielzeitForDate(new Date().toISOString().slice(0, 10)).label;
// If no gagenklasse, return the list of available classes
if (!gagenklasse) {
return Response.json({
gagenklassen: getGagenklassen(),
spielzeit,
});
}
const calculation = calculateDefaultCompensation(
gagenklasse,
yearsOfService,
spielzeit,
fachgruppe,
);
return Response.json({ compensation: calculation });
}
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const {
gagenklasse,
yearsOfService = 0,
fachgruppe = null,
fachgruppeId = null,
spielzeit,
tenantId,
} = body as any;
if (!gagenklasse) {
return Response.json(
{ error: "gagenklasse is required." },
{ status: 400 },
);
}
const effectiveSpielzeit =
spielzeit ||
getSpielzeitForDate(new Date().toISOString().slice(0, 10)).label;
// Try to find DB rules first (tenant-specific or system-wide)
if (tenantId || fachgruppeId) {
const conditions: SQL[] = [];
if (fachgruppeId) {
conditions.push(eq(compensationRules.fachgruppeId, fachgruppeId));
}
if (tenantId) {
conditions.push(eq(compensationRules.tenantId, tenantId));
} else {
conditions.push(isNull(compensationRules.tenantId));
}
const rules = await db
.select()
.from(compensationRules)
.where(and(...conditions));
if (rules.length > 0) {
const components = rules.map((r) => ({
component: r.component,
label: r.label,
amountCents: r.amountCents,
legalBasis: r.legalBasis,
description: r.description,
}));
const calculation = calculateCompensationFromRules(
components,
effectiveSpielzeit,
gagenklasse,
fachgruppe,
);
return Response.json({
compensation: calculation,
source: "database",
});
}
}
// Fallback to default tariff values
try {
const calculation = calculateDefaultCompensation(
gagenklasse,
Number(yearsOfService),
effectiveSpielzeit,
fachgruppe,
);
return Response.json({
compensation: calculation,
source: "default_tariff",
});
} catch (err: any) {
return Response.json({ error: err.message }, { status: 400 });
}
}

View File

@@ -0,0 +1,76 @@
// GET /api/nv-buehne/deadline-check — check non-renewal deadline for given parameters
// POST /api/nv-buehne/deadline-check — check deadline with contract data from body
import {
calculateNonRenewalDeadline,
checkNonRenewalStatus,
getSpielzeitForDate,
} from "@/lib/nv-buehne";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const yearsOfService = parseInt(url.searchParams.get("yearsOfService") || "0", 10);
const isOver55 = url.searchParams.get("isOver55") === "true";
const fachgruppe = url.searchParams.get("fachgruppe") || undefined;
const spielzeit = url.searchParams.get("spielzeit");
const referenceDate =
url.searchParams.get("referenceDate") || new Date().toISOString().slice(0, 10);
// If no Spielzeit given, derive from reference date
const effectiveSpielzeit =
spielzeit || getSpielzeitForDate(referenceDate).label;
const deadline = calculateNonRenewalDeadline({
yearsOfService,
isOver55,
spielzeit: effectiveSpielzeit,
fachgruppe,
});
const status = checkNonRenewalStatus(deadline, referenceDate);
return Response.json({
deadline,
status,
referenceDate,
});
}
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const {
yearsOfService = 0,
isOver55 = false,
spielzeit,
fachgruppe,
referenceDate,
} = body as any;
const effectiveRefDate =
referenceDate || new Date().toISOString().slice(0, 10);
const effectiveSpielzeit =
spielzeit || getSpielzeitForDate(effectiveRefDate).label;
const deadline = calculateNonRenewalDeadline({
yearsOfService: Number(yearsOfService),
isOver55: Boolean(isOver55),
spielzeit: effectiveSpielzeit,
fachgruppe,
});
const status = checkNonRenewalStatus(deadline, effectiveRefDate);
return Response.json({
deadline,
status,
referenceDate: effectiveRefDate,
});
}

View File

@@ -0,0 +1,67 @@
// GET /api/nv-buehne/spielzeit — get Spielzeit calendar and season info
import {
getSpielzeit,
getSpielzeitForDate,
getSpielzeitCalendar,
isInProbenzeit,
isInSpielzeit,
} from "@/lib/nv-buehne";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const spielzeit = url.searchParams.get("spielzeit");
const date = url.searchParams.get("date");
const calendarFrom = url.searchParams.get("calendarFrom");
const calendarCount = parseInt(url.searchParams.get("calendarCount") || "5", 10);
// If a specific Spielzeit is requested
if (spielzeit) {
try {
const sz = getSpielzeit(spielzeit);
const today = new Date().toISOString().slice(0, 10);
return Response.json({
spielzeit: sz,
isCurrentlyActive: isInSpielzeit(today, spielzeit),
isInProbenzeit: isInProbenzeit(today, spielzeit),
});
} catch (err: any) {
return Response.json({ error: err.message }, { status: 400 });
}
}
// If a date is given, return the Spielzeit it falls in
if (date) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return Response.json(
{ error: "date must be YYYY-MM-DD." },
{ status: 400 },
);
}
const sz = getSpielzeitForDate(date);
return Response.json({
spielzeit: sz,
requestedDate: date,
isInProbenzeit: isInProbenzeit(date, sz.label),
});
}
// Default: return a calendar of Spielzeiten
const fromYear = calendarFrom
? parseInt(calendarFrom, 10)
: new Date().getFullYear() - 1;
const calendar = getSpielzeitCalendar(fromYear, Math.min(calendarCount, 20));
const today = new Date().toISOString().slice(0, 10);
const currentSz = getSpielzeitForDate(today);
return Response.json({
current: {
spielzeit: currentSz,
isInProbenzeit: isInProbenzeit(today, currentSz.label),
},
calendar,
});
}

View File

@@ -433,6 +433,465 @@ export const analyses = pgTable(
],
);
// ============================================================
// Vertragsanalyse (Contract Analysis Module — Phase 3.3)
// ============================================================
/** Status of contract document processing */
export const contractDocStatusEnum = pgEnum("contract_doc_status", [
"uploaded",
"extracting",
"extracted",
"analyzing",
"completed",
"failed",
]);
/** Clause assessment rating */
export const clauseRatingEnum = pgEnum("clause_rating", [
"standard", // Matches NV Bühne standard
"abweichend", // Deviates from standard
"kritisch", // Critical deviation — needs attention
"unbekannt", // Unknown / not classifiable
]);
/**
* Vertragsdokumente — uploaded contract documents for analysis.
* Documents are extracted to text, then analyzed clause by clause.
*/
export const contractDocuments = pgTable(
"contract_documents",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
userId: uuid("user_id").notNull().references(() => users.id),
/** Original filename */
filename: varchar("filename", { length: 500 }).notNull(),
/** MIME type (application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document) */
mimeType: varchar("mime_type", { length: 100 }).notNull(),
/** File size in bytes */
fileSizeBytes: integer("file_size_bytes").notNull(),
/** Storage path (relative to upload directory) */
storagePath: text("storage_path").notNull(),
/** Extracted plain text from the document */
extractedText: text("extracted_text"),
/** Processing status */
status: contractDocStatusEnum("status").notNull().default("uploaded"),
/** Error message if processing failed */
errorMessage: text("error_message"),
/** NV Bühne Fachgruppe if identifiable from contract */
fachgruppeId: uuid("fachgruppe_id").references(() => nvBuehneFachgruppen.id),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
/** DSGVO: scheduled deletion date */
deleteAfter: timestamp("delete_after", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("contract_docs_tenant_idx").on(t.tenantId),
index("contract_docs_case_idx").on(t.caseId),
index("contract_docs_status_idx").on(t.status),
index("contract_docs_delete_after_idx").on(t.deleteAfter),
],
);
/**
* Standardklauseln — reference standard clauses from NV Bühne and related agreements.
* Used as comparison baseline for contract analysis.
*/
export const standardClauses = pgTable(
"standard_clauses",
{
id: uuid("id").primaryKey().defaultRandom(),
/** Which instrument this standard clause belongs to */
instrumentId: uuid("instrument_id").notNull().references(() => normInstruments.id, { onDelete: "cascade" }),
/** Category/section of the clause (e.g. "Vergütung", "Kündigung", "Nichtverlängerung") */
category: varchar("category", { length: 200 }).notNull(),
/** Short label for display */
label: varchar("label", { length: 500 }).notNull(),
/** The standard clause text */
body: text("body").notNull(),
/** Applicable Fachgruppen (null = all) */
fachgruppeIds: jsonb("fachgruppe_ids").$type<string[]>(),
/** Reference to the specific norm paragraph */
normId: uuid("norm_id").references(() => norms.id),
/** Sort order within category */
sortOrder: integer("sort_order").default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("standard_clauses_instrument_idx").on(t.instrumentId),
index("standard_clauses_category_idx").on(t.category),
],
);
/**
* Vertragsklauseln — clauses identified in uploaded contract documents.
* Each clause is compared against standard clauses and rated.
*/
export const contractClauses = pgTable(
"contract_clauses",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id").notNull().references(() => contractDocuments.id, { onDelete: "cascade" }),
/** Category of the clause (matched to standardClauses categories) */
category: varchar("category", { length: 200 }).notNull(),
/** The clause text as extracted from the document */
extractedText: text("extracted_text").notNull(),
/** Position in the document (character offset) */
positionStart: integer("position_start"),
positionEnd: integer("position_end"),
/** Matched standard clause for comparison (null if no match) */
standardClauseId: uuid("standard_clause_id").references(() => standardClauses.id),
/** AI assessment of the clause */
rating: clauseRatingEnum("rating").notNull().default("unbekannt"),
/** AI-generated analysis/explanation of the clause */
analysis: text("analysis"),
/** Specific deviations from the standard (if any) */
deviations: jsonb("deviations").$type<string[]>(),
/** Risk score 0-100 (higher = more risk) */
riskScore: integer("risk_score"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("contract_clauses_doc_idx").on(t.documentId),
index("contract_clauses_category_idx").on(t.category),
index("contract_clauses_rating_idx").on(t.rating),
],
);
// ============================================================
// Verfahren (Proceedings — Phase 3.1 BSchGO/ArbGG)
// ============================================================
/** Type of proceeding */
export const proceedingTypeEnum = pgEnum("proceeding_type", [
"bschgo_bezirk", // BSchGO Bezirksschiedsgericht (regional arbitration)
"bschgo_bund", // BSchGO Bundesschiedsgericht (federal arbitration appeal)
"arbgg_erste_instanz", // ArbGG Arbeitsgericht (labor court 1st instance)
"arbgg_berufung", // ArbGG Landesarbeitsgericht (appeal)
"arbgg_revision", // ArbGG Bundesarbeitsgericht (revision)
]);
/** Status of a proceeding */
export const proceedingStatusEnum = pgEnum("proceeding_status", [
"vorbereitung", // Preparation — before filing
"eingereicht", // Filed / submitted
"laufend", // Active / in progress
"verhandlung", // Hearing phase
"entschieden", // Decided (awaiting written decision)
"abgeschlossen", // Completed / closed
"ruht", // Suspended / resting
]);
/** Status of an individual proceeding step */
export const proceedingStepStatusEnum = pgEnum("proceeding_step_status", [
"ausstehend", // Pending
"aktiv", // Active / current step
"abgeschlossen", // Completed
"uebersprungen", // Skipped (e.g. Gütetermin waived)
]);
/** Type of deadline */
export const deadlineTypeEnum = pgEnum("deadline_type", [
"frist", // Procedural deadline (Frist)
"termin", // Hearing/appointment date (Termin)
"vorfrist", // Pre-deadline reminder (Vorfrist)
]);
/**
* Verfahren — proceedings (BSchGO arbitration or ArbGG labor court).
* Tracks the full lifecycle of a legal proceeding linked to a case.
*/
export const proceedings = pgTable(
"proceedings",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
/** Type of proceeding (BSchGO or ArbGG variant) */
type: proceedingTypeEnum("type").notNull(),
/** Overall status */
status: proceedingStatusEnum("status").notNull().default("vorbereitung"),
/** Filing / submission date */
filingDate: date("filing_date"),
/** Internal reference (Aktenzeichen des Anwalts) */
internalRef: varchar("internal_ref", { length: 100 }),
/** Court/tribunal case reference (Aktenzeichen) */
externalRef: varchar("external_ref", { length: 100 }),
/** Tribunal handling the case (for BSchGO) */
tribunalId: uuid("tribunal_id").references(() => arbitrationTribunals.id),
/** Court name (for ArbGG) */
courtName: varchar("court_name", { length: 255 }),
/** Chamber / Kammer */
chamber: varchar("chamber", { length: 100 }),
/** Presiding judge or arbitrator */
presidingJudge: varchar("presiding_judge", { length: 255 }),
/** Applicant / Kläger */
applicant: varchar("applicant", { length: 255 }),
/** Respondent / Beklagter */
respondent: varchar("respondent", { length: 255 }),
/** Subject matter description */
subject: text("subject"),
/** Amount in dispute (Streitwert) in cents */
amountInDisputeCents: integer("amount_in_dispute_cents"),
/** NV Bühne Fachgruppe if applicable */
fachgruppeId: uuid("fachgruppe_id").references(() => nvBuehneFachgruppen.id),
/** The identifier of the currently active step */
currentStepKey: varchar("current_step_key", { length: 100 }),
/** Notes */
notes: text("notes"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
closedAt: timestamp("closed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("proceedings_tenant_idx").on(t.tenantId),
index("proceedings_case_idx").on(t.caseId),
index("proceedings_type_idx").on(t.type),
index("proceedings_status_idx").on(t.status),
],
);
/**
* Verfahrensschritte — individual steps in a proceeding.
* Each step maps to a procedural stage (e.g. "Antrag", "Gütetermin", "Kammertermin").
* Steps are pre-populated from the workflow template and tracked individually.
*/
export const proceedingSteps = pgTable(
"proceeding_steps",
{
id: uuid("id").primaryKey().defaultRandom(),
proceedingId: uuid("proceeding_id").notNull().references(() => proceedings.id, { onDelete: "cascade" }),
/** Key identifying this step in the workflow (e.g. "antrag", "guetetermin") */
stepKey: varchar("step_key", { length: 100 }).notNull(),
/** Human-readable label */
label: varchar("label", { length: 255 }).notNull(),
/** Longer description of what happens in this step */
description: text("description"),
/** Order within the proceeding flow */
sortOrder: integer("sort_order").notNull().default(0),
/** Status of this step */
status: proceedingStepStatusEnum("status").notNull().default("ausstehend"),
/** Applicable legal basis (e.g. "§ 14 BSchGO", "§ 54 ArbGG") */
legalBasis: varchar("legal_basis", { length: 255 }),
/** Responsible party (e.g. "Kläger", "Gericht", "Schiedsrichter") */
responsibleParty: varchar("responsible_party", { length: 255 }),
/** Actual completion date */
completedAt: timestamp("completed_at", { withTimezone: true }),
/** Notes specific to this step */
notes: text("notes"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("proceeding_steps_proceeding_idx").on(t.proceedingId),
uniqueIndex("proceeding_steps_key_idx").on(t.proceedingId, t.stepKey),
],
);
/**
* Fristen — deadlines associated with a proceeding or a specific step.
* Supports both calculated and manually set deadlines, with pre-deadline warnings.
*/
export const proceedingDeadlines = pgTable(
"proceeding_deadlines",
{
id: uuid("id").primaryKey().defaultRandom(),
proceedingId: uuid("proceeding_id").notNull().references(() => proceedings.id, { onDelete: "cascade" }),
/** Optional link to a specific step */
stepId: uuid("step_id").references(() => proceedingSteps.id, { onDelete: "cascade" }),
/** Type of deadline */
type: deadlineTypeEnum("type").notNull().default("frist"),
/** Short label (e.g. "Klagebegründungsfrist", "Gütetermin") */
label: varchar("label", { length: 255 }).notNull(),
/** Description / legal basis for the deadline */
description: text("description"),
/** The deadline date (or appointment date) */
dueDate: date("due_date").notNull(),
/** Optional specific time */
dueTime: varchar("due_time", { length: 10 }),
/** Pre-deadline warning date */
warningDate: date("warning_date"),
/** Number of days before dueDate for the warning (used to auto-calculate warningDate) */
warningDaysBefore: integer("warning_days_before"),
/** Whether this deadline has been met / completed */
isCompleted: boolean("is_completed").default(false),
completedAt: timestamp("completed_at", { withTimezone: true }),
/** Whether the deadline was calculated by the system or set manually */
isCalculated: boolean("is_calculated").default(false),
/** Calculation basis — explains how the deadline was derived */
calculationBasis: text("calculation_basis"),
/** Legal basis for the deadline period */
legalBasis: varchar("legal_basis", { length: 255 }),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("proceeding_deadlines_proceeding_idx").on(t.proceedingId),
index("proceeding_deadlines_step_idx").on(t.stepId),
index("proceeding_deadlines_due_date_idx").on(t.dueDate),
index("proceeding_deadlines_warning_idx").on(t.warningDate),
],
);
// ============================================================
// Nichtverlängerung & Vergütung (Non-Renewal & Compensation — Phase 3.2)
// ============================================================
/** Contract status */
export const contractStatusEnum = pgEnum("contract_status", [
"aktiv", // Active contract
"gekuendigt", // Terminated / notice given
"nichtverlaengert", // Non-renewal notice issued
"ausgelaufen", // Expired
"ruhend", // Suspended
]);
/** Compensation component type */
export const compensationComponentEnum = pgEnum("compensation_component", [
"grundgage", // Base salary (Gagenklasse)
"ortszuschlag", // Location allowance
"kinderzuschlag", // Child allowance
"dienstalterszulage", // Seniority allowance
"funktionszulage", // Function/role allowance
"sonderzahlung", // Special payment (e.g. Weihnachtsgeld)
"probenzuschlag", // Rehearsal supplement
]);
/**
* Verträge — NV Bühne employment contracts for stage artists.
* Tracks contract periods, Fachgruppe, and non-renewal status.
*/
export const contracts = pgTable(
"contracts",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
caseId: uuid("case_id").references(() => cases.id, { onDelete: "set null" }),
/** Name of the contract holder (stage artist) */
artistName: varchar("artist_name", { length: 255 }).notNull(),
/** Employing theater / Bühne */
theaterName: varchar("theater_name", { length: 255 }).notNull(),
/** NV Bühne Fachgruppe */
fachgruppeId: uuid("fachgruppe_id").references(() => nvBuehneFachgruppen.id),
/** Contract status */
status: contractStatusEnum("status").notNull().default("aktiv"),
/** First day of the contract (typically 1.8. of a Spielzeit) */
contractStart: date("contract_start").notNull(),
/** Last day of the contract (typically 31.7. of the final Spielzeit) */
contractEnd: date("contract_end").notNull(),
/** Years of service at this theater (Dienstjahre, relevant for § 61 NV Bühne) */
yearsOfService: integer("years_of_service").notNull().default(0),
/** Whether this is a first engagement at this theater (Erstengagement) */
isFirstEngagement: boolean("is_first_engagement").default(false),
/** Whether the artist is over 55 (extended protection per § 61 Abs. 3) */
isOver55: boolean("is_over_55").default(false),
/** Current Spielzeit season (e.g. "2025/2026") */
currentSpielzeit: varchar("current_spielzeit", { length: 20 }),
/** Gagenklasse — salary class per tariff agreement */
gagenklasse: varchar("gagenklasse", { length: 50 }),
/** Monthly gross compensation in cents */
monthlyGrossCents: integer("monthly_gross_cents"),
notes: text("notes"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("contracts_tenant_idx").on(t.tenantId),
index("contracts_case_idx").on(t.caseId),
index("contracts_fachgruppe_idx").on(t.fachgruppeId),
index("contracts_status_idx").on(t.status),
index("contracts_end_idx").on(t.contractEnd),
],
);
/**
* Vergütungsregeln — compensation rules per Gagenklasse / Fachgruppe / tariff.
* Defines the tariff-based salary structure.
*/
export const compensationRules = pgTable(
"compensation_rules",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
/** Fachgruppe this rule applies to (null = all) */
fachgruppeId: uuid("fachgruppe_id").references(() => nvBuehneFachgruppen.id),
/** Type of compensation component */
component: compensationComponentEnum("component").notNull(),
/** Gagenklasse (e.g. "I", "II", "III", etc.) — null for components applying across all classes */
gagenklasse: varchar("gagenklasse", { length: 50 }),
/** Human-readable label */
label: varchar("label", { length: 255 }).notNull(),
/** Amount in cents (monthly unless otherwise noted) */
amountCents: integer("amount_cents").notNull(),
/** Minimum years of service for this rule to apply */
minYearsOfService: integer("min_years_of_service").default(0),
/** Maximum years of service (null = no upper limit) */
maxYearsOfService: integer("max_years_of_service"),
/** Spielzeit this tariff applies from (e.g. "2025/2026") */
validFromSpielzeit: varchar("valid_from_spielzeit", { length: 20 }),
/** Spielzeit this tariff applies until (null = still current) */
validToSpielzeit: varchar("valid_to_spielzeit", { length: 20 }),
/** Legal basis / Tarifvertrag reference */
legalBasis: varchar("legal_basis", { length: 255 }),
/** Description / notes on this rule */
description: text("description"),
/** Sort order for display */
sortOrder: integer("sort_order").default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("compensation_rules_tenant_idx").on(t.tenantId),
index("compensation_rules_fachgruppe_idx").on(t.fachgruppeId),
index("compensation_rules_component_idx").on(t.component),
index("compensation_rules_gagenklasse_idx").on(t.gagenklasse),
],
);
/**
* Nichtverlängerungsfristen — non-renewal deadlines per NV Bühne § 61.
* Captures the specific deadline rules based on years of service and Fachgruppe.
*/
export const nonRenewalDeadlines = pgTable(
"non_renewal_deadlines",
{
id: uuid("id").primaryKey().defaultRandom(),
/** Reference to the contract */
contractId: uuid("contract_id").notNull().references(() => contracts.id, { onDelete: "cascade" }),
/** The Spielzeit this non-renewal deadline applies to */
spielzeit: varchar("spielzeit", { length: 20 }).notNull(),
/** Absolute deadline date for the Nichtverlängerungsmitteilung */
deadlineDate: date("deadline_date").notNull(),
/** Warning date (advance reminder) */
warningDate: date("warning_date"),
/** Days before deadline for the warning */
warningDaysBefore: integer("warning_days_before").default(30),
/** Whether non-renewal notice was sent */
noticeSent: boolean("notice_sent").default(false),
/** Date notice was actually sent */
noticeSentDate: date("notice_sent_date"),
/** Whether the deadline has passed without notice (= automatic renewal) */
isAutoRenewed: boolean("is_auto_renewed").default(false),
/** Legal basis (e.g. "§ 61 Abs. 1 NV Bühne", "§ 61 Abs. 3 NV Bühne") */
legalBasis: varchar("legal_basis", { length: 255 }).notNull(),
/** Calculation explanation */
calculationBasis: text("calculation_basis"),
/** Special protection category (e.g. "15+ Dienstjahre", "über 55") */
protectionCategory: varchar("protection_category", { length: 100 }),
notes: text("notes"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => [
index("non_renewal_deadlines_contract_idx").on(t.contractId),
index("non_renewal_deadlines_date_idx").on(t.deadlineDate),
index("non_renewal_deadlines_spielzeit_idx").on(t.spielzeit),
],
);
// ============================================================
// DSGVO / Audit
// ============================================================
@@ -517,6 +976,7 @@ export const casesRelations = relations(cases, ({ one, many }) => ({
tenant: one(tenants, { fields: [cases.tenantId], references: [tenants.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [cases.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
analyses: many(analyses),
proceedings: many(proceedings),
}));
export const analysesRelations = relations(analyses, ({ one }) => ({
@@ -524,3 +984,56 @@ export const analysesRelations = relations(analyses, ({ one }) => ({
case: one(cases, { fields: [analyses.caseId], references: [cases.id] }),
user: one(users, { fields: [analyses.userId], references: [users.id] }),
}));
export const contractDocumentsRelations = relations(contractDocuments, ({ one, many }) => ({
tenant: one(tenants, { fields: [contractDocuments.tenantId], references: [tenants.id] }),
case: one(cases, { fields: [contractDocuments.caseId], references: [cases.id] }),
user: one(users, { fields: [contractDocuments.userId], references: [users.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [contractDocuments.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
clauses: many(contractClauses),
}));
export const standardClausesRelations = relations(standardClauses, ({ one }) => ({
instrument: one(normInstruments, { fields: [standardClauses.instrumentId], references: [normInstruments.id] }),
norm: one(norms, { fields: [standardClauses.normId], references: [norms.id] }),
}));
export const contractClausesRelations = relations(contractClauses, ({ one }) => ({
document: one(contractDocuments, { fields: [contractClauses.documentId], references: [contractDocuments.id] }),
standardClause: one(standardClauses, { fields: [contractClauses.standardClauseId], references: [standardClauses.id] }),
}));
export const proceedingsRelations = relations(proceedings, ({ one, many }) => ({
tenant: one(tenants, { fields: [proceedings.tenantId], references: [tenants.id] }),
case: one(cases, { fields: [proceedings.caseId], references: [cases.id] }),
tribunal: one(arbitrationTribunals, { fields: [proceedings.tribunalId], references: [arbitrationTribunals.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [proceedings.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
steps: many(proceedingSteps),
deadlines: many(proceedingDeadlines),
}));
export const proceedingStepsRelations = relations(proceedingSteps, ({ one, many }) => ({
proceeding: one(proceedings, { fields: [proceedingSteps.proceedingId], references: [proceedings.id] }),
deadlines: many(proceedingDeadlines),
}));
export const proceedingDeadlinesRelations = relations(proceedingDeadlines, ({ one }) => ({
proceeding: one(proceedings, { fields: [proceedingDeadlines.proceedingId], references: [proceedings.id] }),
step: one(proceedingSteps, { fields: [proceedingDeadlines.stepId], references: [proceedingSteps.id] }),
}));
export const contractsRelations = relations(contracts, ({ one, many }) => ({
tenant: one(tenants, { fields: [contracts.tenantId], references: [tenants.id] }),
case: one(cases, { fields: [contracts.caseId], references: [cases.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [contracts.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
nonRenewalDeadlines: many(nonRenewalDeadlines),
}));
export const compensationRulesRelations = relations(compensationRules, ({ one }) => ({
tenant: one(tenants, { fields: [compensationRules.tenantId], references: [tenants.id] }),
fachgruppe: one(nvBuehneFachgruppen, { fields: [compensationRules.fachgruppeId], references: [nvBuehneFachgruppen.id] }),
}));
export const nonRenewalDeadlinesRelations = relations(nonRenewalDeadlines, ({ one }) => ({
contract: one(contracts, { fields: [nonRenewalDeadlines.contractId], references: [contracts.id] }),
}));

View File

@@ -0,0 +1,153 @@
// Compensation calculation for NV Bühne contracts.
// Implements Gagenklassen (salary classes), Zulagen (allowances),
// and tariff-based compensation per NV Bühne collective agreement.
export interface CompensationComponent {
component: string;
label: string;
amountCents: number;
legalBasis: string | null;
description: string | null;
}
export interface CompensationCalculation {
/** Monthly gross compensation in cents */
monthlyGrossCents: number;
/** Annual gross compensation in cents (12 months) */
annualGrossCents: number;
/** Breakdown of individual components */
components: CompensationComponent[];
/** Spielzeit the calculation applies to */
spielzeit: string;
/** Gagenklasse used */
gagenklasse: string;
/** Fachgruppe */
fachgruppe: string | null;
/** Calculation notes */
notes: string[];
}
/**
* NV Bühne Gagenklassen — standard base salary structure.
* These are reference values; actual tariff values should come from
* the compensationRules table. This provides fallback defaults.
*
* Values are monthly gross in cents (approximate, based on typical
* NV Bühne tariff agreements — actual amounts depend on the specific
* Haustarifvertrag or NV Bühne version in effect).
*/
const DEFAULT_GAGENKLASSEN: Record<string, number> = {
"I": 250000, // €2,500 — Anfängergage
"II": 280000, // €2,800
"III": 320000, // €3,200
"IV": 370000, // €3,700
"V": 430000, // €4,300
"VI": 500000, // €5,000
"VII": 580000, // €5,800
"VIII": 680000, // €6,800
"IX": 800000, // €8,000
"X": 950000, // €9,500 — Spitzengage
};
/**
* Default Dienstalterszulage (seniority allowance) per year of service.
* Typically a fixed monthly amount per completed year of service.
*/
const DEFAULT_SENIORITY_ALLOWANCE_CENTS = 5000; // €50/month per year
/**
* Calculate total compensation from database rules.
* This is the primary calculation path when rules are available in the DB.
*/
export function calculateCompensationFromRules(
rules: CompensationComponent[],
spielzeit: string,
gagenklasse: string,
fachgruppe: string | null,
): CompensationCalculation {
const monthlyGrossCents = rules.reduce((sum, r) => sum + r.amountCents, 0);
return {
monthlyGrossCents,
annualGrossCents: monthlyGrossCents * 12,
components: rules,
spielzeit,
gagenklasse,
fachgruppe,
notes: [],
};
}
/**
* Calculate compensation using default tariff values (fallback when no DB rules).
* Provides a reasonable estimate based on Gagenklasse and years of service.
*/
export function calculateDefaultCompensation(
gagenklasse: string,
yearsOfService: number,
spielzeit: string,
fachgruppe: string | null,
): CompensationCalculation {
const components: CompensationComponent[] = [];
const notes: string[] = [];
// Base salary (Grundgage)
const baseCents = DEFAULT_GAGENKLASSEN[gagenklasse];
if (!baseCents) {
throw new Error(
`Unbekannte Gagenklasse: "${gagenklasse}". ` +
`Gültige Werte: ${Object.keys(DEFAULT_GAGENKLASSEN).join(", ")}`,
);
}
components.push({
component: "grundgage",
label: `Grundgage Klasse ${gagenklasse}`,
amountCents: baseCents,
legalBasis: "NV Bühne Vergütungstafel",
description: `Monatliche Grundvergütung der Gagenklasse ${gagenklasse}`,
});
// Seniority allowance (Dienstalterszulage)
if (yearsOfService > 0) {
const seniorityAmount = yearsOfService * DEFAULT_SENIORITY_ALLOWANCE_CENTS;
components.push({
component: "dienstalterszulage",
label: `Dienstalterszulage (${yearsOfService} Jahre)`,
amountCents: seniorityAmount,
legalBasis: "NV Bühne § 58",
description: `${yearsOfService} Dienstjahre × ${formatCents(DEFAULT_SENIORITY_ALLOWANCE_CENTS)}/Monat`,
});
}
notes.push(
"Berechnung auf Basis der NV Bühne Standard-Vergütungstafel (Richtwerte). " +
"Tatsächliche Vergütung richtet sich nach dem jeweiligen Haustarifvertrag.",
);
const monthlyGrossCents = components.reduce((sum, c) => sum + c.amountCents, 0);
return {
monthlyGrossCents,
annualGrossCents: monthlyGrossCents * 12,
components,
spielzeit,
gagenklasse,
fachgruppe,
notes,
};
}
/**
* Get the list of valid Gagenklassen.
*/
export function getGagenklassen(): { klasse: string; defaultAmountCents: number }[] {
return Object.entries(DEFAULT_GAGENKLASSEN).map(([klasse, amountCents]) => ({
klasse,
defaultAmountCents: amountCents,
}));
}
/** Format cents as EUR string for display. */
function formatCents(cents: number): string {
return `${(cents / 100).toFixed(2).replace(".", ",")}`;
}

View File

@@ -0,0 +1,28 @@
// NV Bühne module — non-renewal deadlines, compensation, and Spielzeit logic.
export {
calculateNonRenewalDeadline,
calculateNonRenewalDeadlineForDate,
checkNonRenewalStatus,
type NonRenewalDeadlineResult,
type NonRenewalInput,
} from "./non-renewal";
export {
calculateCompensationFromRules,
calculateDefaultCompensation,
getGagenklassen,
type CompensationCalculation,
type CompensationComponent,
} from "./compensation";
export {
getSpielzeit,
getSpielzeitForDate,
getNextSpielzeit,
getSpielzeitCalendar,
isInProbenzeit,
isInSpielzeit,
parseSpielzeitLabel,
type Spielzeit,
} from "./spielzeit";

View File

@@ -0,0 +1,217 @@
// NV Bühne § 61 — Nichtverlängerungsmitteilung (non-renewal notice) logic.
//
// Key rules:
// - Default deadline: 31 October of the current Spielzeit for non-renewal
// of the contract at end of that Spielzeit (31 July next year).
// - Extended protection after 15+ years of service (§ 61 Abs. 2):
// deadline moves earlier to 31 July (a full year before contract end).
// - Artists over 55 with 15+ years (§ 61 Abs. 3): further protection,
// non-renewal only for important reasons (wichtiger Grund).
// - Solo artists (Solokünstler) and ensemble members (Chor, Tanz) may
// have different Fachgruppe-specific rules.
// - If the deadline falls on a weekend or holiday, it does NOT shift
// (unlike procedural deadlines) — NV Bühne deadlines are absolute.
import { getSpielzeit, getSpielzeitForDate, parseSpielzeitLabel } from "./spielzeit";
export interface NonRenewalDeadlineResult {
/** The Spielzeit the notice applies to */
spielzeit: string;
/** Absolute deadline date (YYYY-MM-DD) */
deadlineDate: string;
/** Warning date (YYYY-MM-DD) — typically 30 days before */
warningDate: string;
/** Days before deadline for warning */
warningDaysBefore: number;
/** Legal basis reference */
legalBasis: string;
/** Explanation of the calculation */
calculationBasis: string;
/** Protection category, if applicable */
protectionCategory: string | null;
/** Whether the artist has special protection (unkündbar) */
hasSpecialProtection: boolean;
/** Reason for special protection, if any */
specialProtectionReason: string | null;
}
export interface NonRenewalInput {
/** Years of service at the current theater */
yearsOfService: number;
/** Whether the artist is over 55 years old */
isOver55: boolean;
/** Spielzeit label to check, e.g. "2025/2026" */
spielzeit: string;
/** Fachgruppe abbreviation (e.g. "Solo", "Chor", "Tanz", "BT") */
fachgruppe?: string;
/** Number of warning days before deadline (default: 30) */
warningDaysBefore?: number;
}
/**
* Calculate the non-renewal deadline for a contract according to § 61 NV Bühne.
*
* Rules hierarchy:
* 1. § 61 Abs. 3: 15+ Dienstjahre AND über 55 → practically unkündbar
* (non-renewal only for wichtiger Grund)
* 2. § 61 Abs. 2: 15+ Dienstjahre → deadline 31. Juli
* (one year before contract end)
* 3. § 61 Abs. 1: Standard → deadline 31. Oktober
* (of the current Spielzeit)
*
* For Erstengagement (first engagement), the deadline is also 31.10.
* but may be subject to a shorter initial notice period.
*/
export function calculateNonRenewalDeadline(input: NonRenewalInput): NonRenewalDeadlineResult {
const { yearsOfService, isOver55, spielzeit, warningDaysBefore = 30 } = input;
const { startYear } = parseSpielzeitLabel(spielzeit);
const sz = getSpielzeit(spielzeit);
// § 61 Abs. 3: Over 55 with 15+ years — practically non-terminable
if (isOver55 && yearsOfService >= 15) {
return {
spielzeit,
// Still report the extended deadline for documentation
deadlineDate: `${startYear}-07-31`,
warningDate: subtractDays(`${startYear}-07-31`, warningDaysBefore),
warningDaysBefore,
legalBasis: "§ 61 Abs. 3 NV Bühne",
calculationBasis:
`Künstler/in über 55 Jahre mit ${yearsOfService} Dienstjahren (≥ 15). ` +
`Nichtverlängerung nur aus wichtigem Grund möglich (§ 61 Abs. 3 NV Bühne). ` +
`Theoretische Frist: 31.07.${startYear} (ein Jahr vor Spielzeitende ${sz.end}).`,
protectionCategory: "über 55, 15+ Dienstjahre",
hasSpecialProtection: true,
specialProtectionReason:
"Nichtverlängerung nur aus wichtigem Grund gemäß § 61 Abs. 3 NV Bühne. " +
"Faktischer Bestandsschutz für langjährige Bühnenkünstler über 55.",
};
}
// § 61 Abs. 2: 15+ years of service — extended deadline (31 July)
if (yearsOfService >= 15) {
const deadlineDate = `${startYear}-07-31`;
return {
spielzeit,
deadlineDate,
warningDate: subtractDays(deadlineDate, warningDaysBefore),
warningDaysBefore,
legalBasis: "§ 61 Abs. 2 NV Bühne",
calculationBasis:
`${yearsOfService} Dienstjahre (≥ 15): Verlängerte Frist gem. § 61 Abs. 2 NV Bühne. ` +
`Nichtverlängerungsmitteilung bis spätestens 31.07.${startYear} ` +
`(ein volles Jahr vor Ablauf der Spielzeit ${spielzeit}).`,
protectionCategory: "15+ Dienstjahre",
hasSpecialProtection: false,
specialProtectionReason: null,
};
}
// § 61 Abs. 1: Standard deadline (31 October)
const deadlineDate = `${startYear}-10-31`;
return {
spielzeit,
deadlineDate,
warningDate: subtractDays(deadlineDate, warningDaysBefore),
warningDaysBefore,
legalBasis: "§ 61 Abs. 1 NV Bühne",
calculationBasis:
`Standard-Nichtverlängerungsfrist gem. § 61 Abs. 1 NV Bühne. ` +
`Mitteilung bis spätestens 31.10.${startYear} ` +
`für die Spielzeit ${spielzeit} (Vertragsende ${sz.end}).` +
(yearsOfService > 0 ? ` Dienstjahre: ${yearsOfService}.` : ` Erstengagement.`),
protectionCategory: yearsOfService > 0 ? null : "Erstengagement",
hasSpecialProtection: false,
specialProtectionReason: null,
};
}
/**
* Check a non-renewal deadline against a reference date (typically today).
* Returns status info about whether the deadline is approaching, missed, etc.
*/
export function checkNonRenewalStatus(
deadline: NonRenewalDeadlineResult,
referenceDate: string,
): {
isOverdue: boolean;
isInWarningPeriod: boolean;
daysRemaining: number;
status: "ok" | "warnung" | "ueberfaellig" | "schutz";
statusLabel: string;
} {
if (deadline.hasSpecialProtection) {
return {
isOverdue: false,
isInWarningPeriod: false,
daysRemaining: -1,
status: "schutz",
statusLabel: "Besonderer Bestandsschutz — Nichtverlängerung nur aus wichtigem Grund",
};
}
const refMs = new Date(referenceDate).getTime();
const deadlineMs = new Date(deadline.deadlineDate).getTime();
const daysRemaining = Math.ceil((deadlineMs - refMs) / (1000 * 60 * 60 * 24));
if (daysRemaining < 0) {
return {
isOverdue: true,
isInWarningPeriod: false,
daysRemaining,
status: "ueberfaellig",
statusLabel: `Frist abgelaufen seit ${Math.abs(daysRemaining)} Tag(en) — automatische Verlängerung`,
};
}
const warningMs = new Date(deadline.warningDate).getTime();
if (refMs >= warningMs) {
return {
isOverdue: false,
isInWarningPeriod: true,
daysRemaining,
status: "warnung",
statusLabel: `Vorfrist erreicht — noch ${daysRemaining} Tag(e) bis zur Nichtverlängerungsfrist`,
};
}
return {
isOverdue: false,
isInWarningPeriod: false,
daysRemaining,
status: "ok",
statusLabel: `Frist am ${deadline.deadlineDate} — noch ${daysRemaining} Tag(e)`,
};
}
/**
* Batch-check all contracts for upcoming non-renewal deadlines.
* Useful for dashboard/monitoring.
*/
export function calculateNonRenewalDeadlineForDate(
yearsOfService: number,
isOver55: boolean,
referenceDate: string,
fachgruppe?: string,
): NonRenewalDeadlineResult {
const sz = getSpielzeitForDate(referenceDate);
return calculateNonRenewalDeadline({
yearsOfService,
isOver55,
spielzeit: sz.label,
fachgruppe,
});
}
/** Subtract days from a YYYY-MM-DD date string. */
function subtractDays(dateStr: string, days: number): string {
const d = new Date(dateStr);
d.setDate(d.getDate() - days);
return (
d.getFullYear() +
"-" +
String(d.getMonth() + 1).padStart(2, "0") +
"-" +
String(d.getDate()).padStart(2, "0")
);
}

View File

@@ -0,0 +1,90 @@
// Spielzeit (season) utilities for NV Bühne stage law.
// A Spielzeit runs from 1 August to 31 July of the following year.
// Rehearsal periods (Probenzeit) typically begin 4-6 weeks before season start.
export interface Spielzeit {
/** Display label, e.g. "2025/2026" */
label: string;
/** First day of the Spielzeit (1 August) */
start: string;
/** Last day of the Spielzeit (31 July next year) */
end: string;
/** Typical start of rehearsal period (mid-June) */
probenStart: string;
/** End of rehearsal period (31 July, day before season) */
probenEnd: string;
}
/**
* Parse a Spielzeit label like "2025/2026" into start/end years.
*/
export function parseSpielzeitLabel(label: string): { startYear: number; endYear: number } {
const parts = label.split("/");
if (parts.length !== 2) {
throw new Error(`Invalid Spielzeit label: "${label}". Expected format: "YYYY/YYYY".`);
}
return { startYear: parseInt(parts[0], 10), endYear: parseInt(parts[1], 10) };
}
/**
* Build a Spielzeit from a label like "2025/2026".
*/
export function getSpielzeit(label: string): Spielzeit {
const { startYear, endYear } = parseSpielzeitLabel(label);
return {
label,
start: `${startYear}-08-01`,
end: `${endYear}-07-31`,
// Probenzeit: typically mid-June to end of July
probenStart: `${startYear}-06-15`,
probenEnd: `${startYear}-07-31`,
};
}
/**
* Get the Spielzeit that contains a given date.
* Dates from Aug 1 to Jul 31 belong to the same Spielzeit.
*/
export function getSpielzeitForDate(dateStr: string): Spielzeit {
const [y, m] = dateStr.split("-").map(Number);
// Aug-Dec: Spielzeit starts this year
// Jan-Jul: Spielzeit started previous year
const startYear = m >= 8 ? y : y - 1;
const endYear = startYear + 1;
return getSpielzeit(`${startYear}/${endYear}`);
}
/**
* Get the next Spielzeit after the one containing the given date.
*/
export function getNextSpielzeit(dateStr: string): Spielzeit {
const current = getSpielzeitForDate(dateStr);
const { endYear } = parseSpielzeitLabel(current.label);
return getSpielzeit(`${endYear}/${endYear + 1}`);
}
/**
* Generate a calendar of Spielzeiten for a range of years.
*/
export function getSpielzeitCalendar(fromYear: number, count: number): Spielzeit[] {
return Array.from({ length: count }, (_, i) => {
const startYear = fromYear + i;
return getSpielzeit(`${startYear}/${startYear + 1}`);
});
}
/**
* Check if a date falls within a Spielzeit's rehearsal period (Probenzeit).
*/
export function isInProbenzeit(dateStr: string, spielzeitLabel: string): boolean {
const sz = getSpielzeit(spielzeitLabel);
return dateStr >= sz.probenStart && dateStr <= sz.probenEnd;
}
/**
* Check if a date falls within a given Spielzeit.
*/
export function isInSpielzeit(dateStr: string, spielzeitLabel: string): boolean {
const sz = getSpielzeit(spielzeitLabel);
return dateStr >= sz.start && dateStr <= sz.end;
}