diff --git a/drizzle/0003_non_renewal_compensation.sql b/drizzle/0003_non_renewal_compensation.sql new file mode 100644 index 0000000..e047a8d --- /dev/null +++ b/drizzle/0003_non_renewal_compensation.sql @@ -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"); diff --git a/src/app/api/nv-buehne/compensation/route.ts b/src/app/api/nv-buehne/compensation/route.ts new file mode 100644 index 0000000..e228bcd --- /dev/null +++ b/src/app/api/nv-buehne/compensation/route.ts @@ -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; + 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 }); + } +} diff --git a/src/app/api/nv-buehne/deadline-check/route.ts b/src/app/api/nv-buehne/deadline-check/route.ts new file mode 100644 index 0000000..8d5e736 --- /dev/null +++ b/src/app/api/nv-buehne/deadline-check/route.ts @@ -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; + 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, + }); +} diff --git a/src/app/api/nv-buehne/spielzeit/route.ts b/src/app/api/nv-buehne/spielzeit/route.ts new file mode 100644 index 0000000..92df119 --- /dev/null +++ b/src/app/api/nv-buehne/spielzeit/route.ts @@ -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, + }); +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 841b72a..3460ba8 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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>(), + /** 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(), + /** 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(), + /** 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>(), + 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>(), + 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>(), + 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] }), +})); diff --git a/src/lib/nv-buehne/compensation.ts b/src/lib/nv-buehne/compensation.ts new file mode 100644 index 0000000..28adce3 --- /dev/null +++ b/src/lib/nv-buehne/compensation.ts @@ -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 = { + "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(".", ",")}`; +} diff --git a/src/lib/nv-buehne/index.ts b/src/lib/nv-buehne/index.ts new file mode 100644 index 0000000..9e93eca --- /dev/null +++ b/src/lib/nv-buehne/index.ts @@ -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"; diff --git a/src/lib/nv-buehne/non-renewal.ts b/src/lib/nv-buehne/non-renewal.ts new file mode 100644 index 0000000..e77334f --- /dev/null +++ b/src/lib/nv-buehne/non-renewal.ts @@ -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") + ); +} diff --git a/src/lib/nv-buehne/spielzeit.ts b/src/lib/nv-buehne/spielzeit.ts new file mode 100644 index 0000000..309c22d --- /dev/null +++ b/src/lib/nv-buehne/spielzeit.ts @@ -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; +}