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:
77
drizzle/0003_non_renewal_compensation.sql
Normal file
77
drizzle/0003_non_renewal_compensation.sql
Normal 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");
|
||||
127
src/app/api/nv-buehne/compensation/route.ts
Normal file
127
src/app/api/nv-buehne/compensation/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
76
src/app/api/nv-buehne/deadline-check/route.ts
Normal file
76
src/app/api/nv-buehne/deadline-check/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
67
src/app/api/nv-buehne/spielzeit/route.ts
Normal file
67
src/app/api/nv-buehne/spielzeit/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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] }),
|
||||
}));
|
||||
|
||||
153
src/lib/nv-buehne/compensation.ts
Normal file
153
src/lib/nv-buehne/compensation.ts
Normal 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(".", ",")}`;
|
||||
}
|
||||
28
src/lib/nv-buehne/index.ts
Normal file
28
src/lib/nv-buehne/index.ts
Normal 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";
|
||||
217
src/lib/nv-buehne/non-renewal.ts
Normal file
217
src/lib/nv-buehne/non-renewal.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
90
src/lib/nv-buehne/spielzeit.ts
Normal file
90
src/lib/nv-buehne/spielzeit.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user