From b6cb62c62729194a758f4635ae39fe17f1d998ac Mon Sep 17 00:00:00 2001
From: Ediminator <211899731+Ediminator@users.noreply.github.com>
Date: Sat, 7 Nov 2026 02:53:06 +0100
Subject: [PATCH 1/2] feat(contracts): contract templates studio settings UI
with live merge preview (TASK-0065)
Signed-off-by: Ediminator <211899731+Ediminator@users.noreply.github.com>
---
messages/de.json | 207 +++
messages/en.json | 207 +++
src/app/[locale]/settings/contracts/page.tsx | 32 +
src/app/[locale]/settings/page.tsx | 30 +-
.../settings/contract-template-modal.tsx | 480 ++++++
.../settings/contract-templates-list.tsx | 391 +++++
src/components/settings/contracts-helpers.ts | 1140 ++++++++++++++
.../settings/contracts-settings-view.tsx | 194 +++
src/components/settings/settings-nav.tsx | 11 +-
.../settings/studio-signing-settings-card.tsx | 399 +++++
src/server/contracts/actions.ts | 310 ++++
src/server/contracts/schema.ts | 93 +-
tests/e2e/contracts-settings.spec.ts | 437 ++++++
.../contract-template-actions.test.ts | 644 ++++++++
.../contract-template-form-schema.test.ts | 1307 +++++++++++++++++
...tract-templates-components-render.test.tsx | 582 ++++++++
tests/unit/contracts-schema.test.ts | 21 +
17 files changed, 6475 insertions(+), 10 deletions(-)
create mode 100644 src/app/[locale]/settings/contracts/page.tsx
create mode 100644 src/components/settings/contract-template-modal.tsx
create mode 100644 src/components/settings/contract-templates-list.tsx
create mode 100644 src/components/settings/contracts-helpers.ts
create mode 100644 src/components/settings/contracts-settings-view.tsx
create mode 100644 src/components/settings/studio-signing-settings-card.tsx
create mode 100644 src/server/contracts/actions.ts
create mode 100644 tests/e2e/contracts-settings.spec.ts
create mode 100644 tests/integration/contract-template-actions.test.ts
create mode 100644 tests/unit/contract-template-form-schema.test.ts
create mode 100644 tests/unit/contract-templates-components-render.test.tsx
diff --git a/messages/de.json b/messages/de.json
index ec9b268..0733402 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -970,6 +970,213 @@
"defaultTaxRateInvalid": "Standard-Steuersatz muss zwischen 0% und 100% liegen",
"noticeTooLong": "Hinweistext darf maximal 1000 Zeichen lang sein"
}
+ },
+ "contracts": {
+ "title": "Vertragsvorlagen & Studio-Signatur",
+ "description": "Verwalten Sie rechtssichere Vertragsvorlagen, zweisprachige Klauseln, dynamische Platzhalter und Studio-Signaturdaten.",
+ "navLabel": "Verträge & Signatur",
+ "manage": "Vertragsvorlagen & Signatur verwalten",
+ "breadcrumbs": {
+ "settings": "Studio-Einstellungen",
+ "contracts": "Verträge & Signatur"
+ },
+ "tabs": {
+ "tablistAria": "Vertragseinstellungen-Tabs",
+ "templates": "Vertragsvorlagen",
+ "signing": "Studio-Signaturprofil"
+ },
+ "list": {
+ "title": "Vertragsvorlagen",
+ "description": "Wiederverwendbare Vertragsvorlagen mit dynamischen Platzhaltern für Kundenvereinbarungen.",
+ "createButton": "Neue Vorlage",
+ "filterAll": "Alle Projekttypen",
+ "filterByType": "Nach Projekttyp filtern",
+ "filterSearchPlaceholder": "Vorlagen durchsuchen...",
+ "activeCount": "{count, plural, =1 {1 aktive Vorlage} other {# aktive Vorlagen}}",
+ "archivedCount": "{count, plural, =1 {1 archivierte Vorlage} other {# archivierte Vorlagen}}",
+ "showArchived": "Archivierte anzeigen ({count})",
+ "hideArchived": "Archivierte ausblenden",
+ "emptyActive": "Noch keine aktiven Vertragsvorlagen vorhanden. Erstellen Sie Ihre erste Vertragsvorlage, um Angebote und Verträge schneller zu verfassen.",
+ "emptyArchived": "Keine archivierten Vertragsvorlagen.",
+ "table": {
+ "name": "Vorlagenname",
+ "slug": "Slug",
+ "projectType": "Projekttyp",
+ "status": "Status",
+ "actions": "Aktionen"
+ },
+ "projectTypeNone": "Allgemein / Alle",
+ "projectTypes": {
+ "wedding": "Hochzeit",
+ "portrait": "Porträt",
+ "commercial": "Kommerziell",
+ "event": "Event",
+ "video": "Video",
+ "engagement": "Verlobung",
+ "editorial": "Editorial"
+ },
+ "defaultBadge": "Standard",
+ "activeBadge": "Aktiv",
+ "archivedBadge": "Archiviert",
+ "editAction": "Vorlage bearbeiten",
+ "duplicateAction": "Duplizieren",
+ "archiveAction": "Vorlage archivieren",
+ "restoreAction": "Vorlage wiederherstellen",
+ "archiveDialog": {
+ "title": "Vertragsvorlage archivieren",
+ "description": "Sind Sie sicher, dass Sie '{name}' archivieren möchten? Bereits abgeschlossene Verträge bleiben unberührt, diese Vorlage steht jedoch für neue Verträge nicht mehr zur Verfügung.",
+ "confirmButton": "Vorlage archivieren",
+ "cancelButton": "Abbrechen"
+ }
+ },
+ "modal": {
+ "createTitle": "Vertragsvorlage erstellen",
+ "editTitle": "Vertragsvorlage bearbeiten",
+ "duplicateTitle": "Vertragsvorlage duplizieren",
+ "description": "Legen Sie Vorlagendetails, Projekttyp-Zuordnung und zweisprachige Klauseln mit dynamischen Platzhaltern fest.",
+ "nameLabel": "Vorlagenname",
+ "namePlaceholder": "z. B. Vertrag über Hochzeitsfotografie",
+ "slugLabel": "Slug",
+ "slugPlaceholder": "z. B. vertrag-hochzeitsfotografie",
+ "slugHelp": "Nur Kleinbuchstaben, Ziffern und Bindestriche",
+ "projectTypeLabel": "Projekttyp",
+ "projectTypePlaceholder": "Projekttyp auswählen (optional)",
+ "projectTypeNone": "Keine (Allgemein)",
+ "descriptionLabel": "Beschreibung",
+ "descriptionPlaceholder": "Interne Beschreibung des Anwendungsbereichs",
+ "defaultToggleLabel": "Standardvorlage für diesen Projekttyp",
+ "defaultToggleHelp": "Wird bei der Vertragserstellung für diesen Projekttyp automatisch vorausgewählt",
+ "clauseEditorTitle": "Vertragsklauseln",
+ "clauseEditorHelp": "Klicken Sie auf einen Platzhalter unten, um ihn an der Cursorposition einzufügen.",
+ "tabEnglish": "Englische Klauseln (en)",
+ "tabGerman": "Deutsche Klauseln (de)",
+ "contentEnLabel": "Englische Vertragsklauseln",
+ "contentEnPlaceholder": "Vertragsklauseln auf Englisch eingeben (Markdown unterstützt)...",
+ "contentDeLabel": "Deutsche Vertragsklauseln",
+ "contentDePlaceholder": "Vertragsklauseln auf Deutsch eingeben (Markdown unterstützt)...",
+ "tokenInsertedAnnouncement": "Platzhalter {token} eingefügt",
+ "tokenPaletteTitle": "Platzhalter einfügen",
+ "previewTitle": "Live-Vorschau",
+ "previewBadge": "Beispieldaten-Vorschau",
+ "previewEmpty": "Noch keine Vertragsklauseln eingegeben. Geben Sie oben Text ein, um eine Live-Vorschau mit Beispieldaten zu sehen.",
+ "saveButton": "Vorlage speichern",
+ "savingButton": "Speichern...",
+ "cancelButton": "Abbrechen",
+ "errors": {
+ "nameRequired": "Vorlagenname ist erforderlich",
+ "nameTooLong": "Vorlagenname darf maximal 150 Zeichen lang sein",
+ "slugRequired": "Slug ist erforderlich",
+ "slugInvalid": "Slug darf nur aus Kleinbuchstaben und Ziffern mit einfachen Bindestrichen bestehen",
+ "slugTooLong": "Slug darf maximal 100 Zeichen lang sein",
+ "descriptionTooLong": "Beschreibung darf maximal 1.000 Zeichen lang sein",
+ "contentEnRequired": "Englische Vertragsklauseln sind erforderlich",
+ "contentDeRequired": "Deutsche Vertragsklauseln sind erforderlich"
+ }
+ },
+ "signing": {
+ "title": "Studio-Signatur & Gegenzeichnungsprofil",
+ "description": "Konfigurieren Sie die zeichnungsberechtigte Person des Studios, offizielle Berufsbezeichnung, Anschrift und den Gegenzeichnungsprozess.",
+ "signatorySectionTitle": "Signaturprofil des Studios",
+ "signatoryNameLabel": "Rechtlicher Name des Unterzeichners",
+ "signatoryNamePlaceholder": "z. B. Elena Rostova",
+ "signatoryNameHelp": "Vollständiger Name auf Verträgen und in der elektronischen Signaturzeile",
+ "signatoryTitleLabel": "Berufsbezeichnung / Funktion",
+ "signatoryTitlePlaceholder": "z. B. Studioinhaberin & leitende Fotografin",
+ "signatoryTitleHelp": "Gesetzliche Funktionsbezeichnung unter der Unterschrift des Studios",
+ "modeSectionTitle": "Gegenzeichnungs-Modus",
+ "modeSectionHelp": "Legen Sie fest, wie Verträge vom Studio gegengezeichnet werden.",
+ "modes": {
+ "preSigned": {
+ "label": "Automatisch (Vorab gegengezeichnet)",
+ "description": "Die Signatur des Studios wird beim Versand oder bei Kundenunterzeichnung automatisch angebracht."
+ },
+ "explicit": {
+ "label": "Explizit (Manuelle Gegenzeichnung)",
+ "description": "Der Studioinhaber muss nach der Unterschrift des Kunden manuell gegenzeichnen, um den Vertrag abzuschließen."
+ }
+ },
+ "addressSectionTitle": "Studio-Geschäftsadresse & gesetzliche Kontaktdaten",
+ "addressSectionHelp": "Physische Geschäftsadresse und Kontaktdaten für gesetzliche Pflichtangaben (BGB § 126b, DDG § 5).",
+ "addressLine1Label": "Straße und Hausnummer",
+ "addressLine1Placeholder": "z. B. Kastanienallee 71",
+ "addressLine2Label": "Adresszusatz",
+ "addressLine2Placeholder": "z. B. Studio 4B (optional)",
+ "postalCodeLabel": "Postleitzahl",
+ "postalCodePlaceholder": "z. B. 10435",
+ "cityLabel": "Stadt",
+ "cityPlaceholder": "z. B. Berlin",
+ "countryCodeLabel": "Ländercode (ISO 3166-1 Alpha-2)",
+ "countryCodePlaceholder": "DE",
+ "phoneLabel": "Telefonnummer des Studios",
+ "phonePlaceholder": "z. B. +49 30 12345678",
+ "emailLabel": "Offizielle E-Mail-Adresse des Studios",
+ "emailPlaceholder": "z. B. studio@example.com",
+ "saveButton": "Signatur-Einstellungen speichern",
+ "savingButton": "Speichern...",
+ "successNotification": "Studio-Signatur-Einstellungen wurden erfolgreich aktualisiert.",
+ "errors": {
+ "signatoryNameTooLong": "Unterzeichnername darf maximal 200 Zeichen lang sein",
+ "signatoryTitleTooLong": "Berufsbezeichnung darf maximal 100 Zeichen lang sein",
+ "countryCodeInvalid": "Ländercode muss genau 2 Zeichen lang sein (z. B. DE)",
+ "emailInvalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein"
+ }
+ },
+ "tokens": {
+ "categories": {
+ "client": "Kundendaten",
+ "project": "Projekt & Auftrag",
+ "shoot": "Termin- & Eventlogistik",
+ "package": "Paket & Vergütung",
+ "studio": "Studio & Rechtsträger",
+ "contract": "Vertragsdaten"
+ },
+ "client": {
+ "name": "Vollständiger Kundenname",
+ "company": "Kunden-Unternehmensname",
+ "email": "Kunden-E-Mail-Adresse",
+ "phone": "Kunden-Telefonnummer",
+ "address": "Kunden-Straße & Hausnummer",
+ "city": "Kunden-Stadt",
+ "postal_code": "Kunden-Postleitzahl",
+ "country": "Kunden-Land"
+ },
+ "project": {
+ "title": "Projekttitel",
+ "type": "Projekttyp",
+ "price": "Projektpreis",
+ "currency": "Währung",
+ "notes": "Projektnotizen"
+ },
+ "shoot": {
+ "date": "Shooting- / Eventdatum",
+ "time": "Shooting-Uhrzeit (Start & Ende)",
+ "timezone": "Zeitzone",
+ "location": "Shooting-Ort / Location",
+ "address": "Physische Adresse der Location"
+ },
+ "package": {
+ "name": "Name des Leistungspakets",
+ "deliverables": "Übersicht enthaltener Leistungen",
+ "total": "Gesamtbetrag (inkl. Steuern)",
+ "subtotal": "Netto-Zwischensumme",
+ "tax": "Anfallender Steuerbetrag",
+ "addons": "Ausgewählte Zusatzleistungen"
+ },
+ "studio": {
+ "name": "Name des Fotostudios",
+ "owner_name": "Name des Studioinhabers",
+ "email": "Offizielle E-Mail des Studios",
+ "phone": "Telefonnummer des Studios",
+ "address": "Physische Adresse des Studios",
+ "tax_number": "Steuernummer des Studios",
+ "vat_id": "USt-IdNr. des Studios"
+ },
+ "contract": {
+ "number": "Vertragsnummer (CTR-YYYY-NNNN)",
+ "issue_date": "Vertragsausstellungsdatum",
+ "valid_until": "Vertragsgültigkeitsdatum"
+ }
+ }
}
},
"clients": {
diff --git a/messages/en.json b/messages/en.json
index bd05945..fb495ef 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -970,6 +970,213 @@
"defaultTaxRateInvalid": "Default tax rate must be between 0% and 100%",
"noticeTooLong": "Notice text cannot exceed 1000 characters"
}
+ },
+ "contracts": {
+ "title": "Contract Templates & Studio Signing",
+ "description": "Manage legally binding contract agreements, localized bilingual clauses, merge tokens, and studio signatory credentials.",
+ "navLabel": "Contracts & Signing",
+ "manage": "Manage Contract Templates & Signing",
+ "breadcrumbs": {
+ "settings": "Studio Settings",
+ "contracts": "Contracts & Signing"
+ },
+ "tabs": {
+ "tablistAria": "Contract settings tabs",
+ "templates": "Contract Templates",
+ "signing": "Studio Signatory Profile"
+ },
+ "list": {
+ "title": "Contract Templates",
+ "description": "Reusable contract agreements with dynamic merge tokens for client agreements.",
+ "createButton": "New Template",
+ "filterAll": "All Project Types",
+ "filterByType": "Filter by project type",
+ "filterSearchPlaceholder": "Search templates...",
+ "activeCount": "{count, plural, =1 {1 Active Template} other {# Active Templates}}",
+ "archivedCount": "{count, plural, =1 {1 Archived Template} other {# Archived Templates}}",
+ "showArchived": "Show Archived ({count})",
+ "hideArchived": "Hide Archived",
+ "emptyActive": "No active contract templates configured yet. Create your first contract template to speed up proposal and contract drafting.",
+ "emptyArchived": "No archived contract templates.",
+ "table": {
+ "name": "Template Name",
+ "slug": "Slug",
+ "projectType": "Project Type",
+ "status": "Status",
+ "actions": "Actions"
+ },
+ "projectTypeNone": "General / All",
+ "projectTypes": {
+ "wedding": "Wedding",
+ "portrait": "Portrait",
+ "commercial": "Commercial",
+ "event": "Event",
+ "video": "Video",
+ "engagement": "Engagement",
+ "editorial": "Editorial"
+ },
+ "defaultBadge": "Default",
+ "activeBadge": "Active",
+ "archivedBadge": "Archived",
+ "editAction": "Edit Template",
+ "duplicateAction": "Duplicate",
+ "archiveAction": "Archive Template",
+ "restoreAction": "Restore Template",
+ "archiveDialog": {
+ "title": "Archive Contract Template",
+ "description": "Are you sure you want to archive '{name}'? Existing executed contracts will remain untouched, but this template will no longer be available for new contract drafting.",
+ "confirmButton": "Archive Template",
+ "cancelButton": "Cancel"
+ }
+ },
+ "modal": {
+ "createTitle": "Create Contract Template",
+ "editTitle": "Edit Contract Template",
+ "duplicateTitle": "Duplicate Contract Template",
+ "description": "Define template details, project type assignment, and localized clauses with dynamic merge tokens.",
+ "nameLabel": "Template Name",
+ "namePlaceholder": "e.g. Wedding Photography Agreement",
+ "slugLabel": "Slug",
+ "slugPlaceholder": "e.g. wedding-photography-agreement",
+ "slugHelp": "Lowercase letters, numbers, and hyphens only",
+ "projectTypeLabel": "Project Type",
+ "projectTypePlaceholder": "Select project type (optional)",
+ "projectTypeNone": "None (General)",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Internal description of the agreement's scope or purpose",
+ "defaultToggleLabel": "Default template for this project type",
+ "defaultToggleHelp": "Automatically pre-selected when composing contracts for this project type",
+ "clauseEditorTitle": "Contract Clauses",
+ "clauseEditorHelp": "Click any merge token below to insert it at your cursor position.",
+ "tabEnglish": "English Clauses (en)",
+ "tabGerman": "German Clauses (de)",
+ "contentEnLabel": "English Contract Clauses",
+ "contentEnPlaceholder": "Enter contract agreement clauses in English (Markdown supported)...",
+ "contentDeLabel": "German Contract Clauses",
+ "contentDePlaceholder": "Vertragsklauseln auf Deutsch eingeben (Markdown wird unterstützt)...",
+ "tokenInsertedAnnouncement": "Token {token} inserted",
+ "tokenPaletteTitle": "Insert Merge Tokens",
+ "previewTitle": "Live Merge Preview",
+ "previewBadge": "Sample Data Preview",
+ "previewEmpty": "No contract clauses entered yet. Type clauses above to see a live preview interpolated with sample commission data.",
+ "saveButton": "Save Template",
+ "savingButton": "Saving...",
+ "cancelButton": "Cancel",
+ "errors": {
+ "nameRequired": "Template name is required",
+ "nameTooLong": "Template name must not exceed 150 characters",
+ "slugRequired": "Slug is required",
+ "slugInvalid": "Slug must consist of lowercase alphanumeric words separated by single hyphens",
+ "slugTooLong": "Slug must not exceed 100 characters",
+ "descriptionTooLong": "Description must not exceed 1,000 characters",
+ "contentEnRequired": "English contract clauses are required",
+ "contentDeRequired": "German contract clauses are required"
+ }
+ },
+ "signing": {
+ "title": "Studio Signatory & Countersignature Profile",
+ "description": "Configure the studio's legal signatory, official business title, physical contact address, and countersignature workflow.",
+ "signatorySectionTitle": "Legal Signatory Profile",
+ "signatoryNameLabel": "Signatory Legal Name",
+ "signatoryNamePlaceholder": "e.g. Elena Rostova",
+ "signatoryNameHelp": "Full legal name displayed on contracts and electronic signature blocks",
+ "signatoryTitleLabel": "Professional Title / Role",
+ "signatoryTitlePlaceholder": "e.g. Studio Owner & Lead Photographer",
+ "signatoryTitleHelp": "Statutory role shown under studio signature line",
+ "modeSectionTitle": "Countersignature Mode",
+ "modeSectionHelp": "Choose how the studio countersigns contracts when issuing them to clients.",
+ "modes": {
+ "preSigned": {
+ "label": "Automatic (Pre-signed)",
+ "description": "Studio countersignature is automatically applied when the contract is issued or signed by the client."
+ },
+ "explicit": {
+ "label": "Explicit (Manual Countersignature)",
+ "description": "Studio owner must manually sign after the client has signed to finalize execution."
+ }
+ },
+ "addressSectionTitle": "Studio Business Address & Statutory Contact",
+ "addressSectionHelp": "Physical studio premises and contact information rendered in contract legal notices (BGB § 126b, DDG § 5).",
+ "addressLine1Label": "Street Address Line 1",
+ "addressLine1Placeholder": "e.g. Kastanienallee 71",
+ "addressLine2Label": "Street Address Line 2",
+ "addressLine2Placeholder": "e.g. Studio 4B (Optional)",
+ "postalCodeLabel": "Postal Code",
+ "postalCodePlaceholder": "e.g. 10435",
+ "cityLabel": "City",
+ "cityPlaceholder": "e.g. Berlin",
+ "countryCodeLabel": "Country Code (ISO 3166-1 alpha-2)",
+ "countryCodePlaceholder": "DE",
+ "phoneLabel": "Studio Telephone",
+ "phonePlaceholder": "e.g. +49 30 12345678",
+ "emailLabel": "Studio Official Email",
+ "emailPlaceholder": "e.g. studio@example.com",
+ "saveButton": "Save Signing Settings",
+ "savingButton": "Saving...",
+ "successNotification": "Studio signing settings updated successfully.",
+ "errors": {
+ "signatoryNameTooLong": "Signatory name must not exceed 200 characters",
+ "signatoryTitleTooLong": "Signatory title must not exceed 100 characters",
+ "countryCodeInvalid": "Country code must be exactly 2 characters (e.g. DE)",
+ "emailInvalid": "Please enter a valid email address"
+ }
+ },
+ "tokens": {
+ "categories": {
+ "client": "Client Information",
+ "project": "Project & Commission",
+ "shoot": "Event & Shoot Logistics",
+ "package": "Package & Pricing",
+ "studio": "Studio & Legal Entity",
+ "contract": "Contract Metadata"
+ },
+ "client": {
+ "name": "Full Client Name",
+ "company": "Client Company Name",
+ "email": "Client Email Address",
+ "phone": "Client Phone Number",
+ "address": "Client Street Address",
+ "city": "Client City",
+ "postal_code": "Client Postal Code",
+ "country": "Client Country"
+ },
+ "project": {
+ "title": "Project Title",
+ "type": "Project Type",
+ "price": "Project Price",
+ "currency": "Currency",
+ "notes": "Project Scope Notes"
+ },
+ "shoot": {
+ "date": "Shoot / Event Date",
+ "time": "Shoot Start & End Time",
+ "timezone": "Timezone",
+ "location": "Shoot Location / Venue",
+ "address": "Venue Physical Address"
+ },
+ "package": {
+ "name": "Service Package Name",
+ "deliverables": "Included Deliverables Summary",
+ "total": "Package Total Price (incl. Tax)",
+ "subtotal": "Package Net Subtotal",
+ "tax": "Applicable VAT / Tax Amount",
+ "addons": "Selected Add-ons Summary"
+ },
+ "studio": {
+ "name": "Studio Business Name",
+ "owner_name": "Studio Owner Name",
+ "email": "Studio Official Email",
+ "phone": "Studio Telephone",
+ "address": "Studio Physical Address",
+ "tax_number": "Studio Tax Number (Steuernummer)",
+ "vat_id": "Studio VAT ID (USt-IdNr.)"
+ },
+ "contract": {
+ "number": "Contract Number (CTR-YYYY-NNNN)",
+ "issue_date": "Contract Issue Date",
+ "valid_until": "Contract Validity Expiry Date"
+ }
+ }
}
},
"clients": {
diff --git a/src/app/[locale]/settings/contracts/page.tsx b/src/app/[locale]/settings/contracts/page.tsx
new file mode 100644
index 0000000..7e5ef78
--- /dev/null
+++ b/src/app/[locale]/settings/contracts/page.tsx
@@ -0,0 +1,32 @@
+import { redirect } from 'next/navigation';
+import { requireOwner } from '@/server/auth/guards';
+import { listContractTemplates } from '@/server/contracts/template-repo';
+import { getStudioSigningProfile } from '@/server/studio/signing-settings';
+import { SettingsNav } from '@/components/settings/settings-nav';
+import { ContractsSettingsView } from '@/components/settings/contracts-settings-view';
+
+export default async function ContractsSettingsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+
+ try {
+ await requireOwner();
+ } catch {
+ redirect(`/${locale}/sign-in?callbackUrl=/${locale}/settings/contracts`);
+ }
+
+ const [templates, signingProfile] = await Promise.all([
+ listContractTemplates(),
+ getStudioSigningProfile(),
+ ]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/settings/page.tsx b/src/app/[locale]/settings/page.tsx
index ce59ebf..ab5c8f2 100644
--- a/src/app/[locale]/settings/page.tsx
+++ b/src/app/[locale]/settings/page.tsx
@@ -1,6 +1,14 @@
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
-import { Shield, Cloud, ClipboardList, CalendarClock, CalendarSync, Package } from 'lucide-react';
+import {
+ Shield,
+ Cloud,
+ ClipboardList,
+ CalendarClock,
+ CalendarSync,
+ Package,
+ FileText,
+} from 'lucide-react';
export default function SettingsPage() {
const t = useTranslations('pages.settings');
@@ -9,6 +17,7 @@ export default function SettingsPage() {
const tInq = useTranslations('settings.inquiryForms');
const tAvail = useTranslations('settings.availability');
const tPkg = useTranslations('settings.packages');
+ const tContract = useTranslations('settings.contracts');
return (
@@ -35,6 +44,25 @@ export default function SettingsPage() {
+
+
+
+
+
+
{tContract('title')}
+
{tContract('description')}
+
+
+
+
+ {tContract('manage')}
+
+
+
diff --git a/src/components/settings/contract-template-modal.tsx b/src/components/settings/contract-template-modal.tsx
new file mode 100644
index 0000000..7439c6f
--- /dev/null
+++ b/src/components/settings/contract-template-modal.tsx
@@ -0,0 +1,480 @@
+'use client';
+
+import * as React from 'react';
+import { useTranslations } from 'next-intl';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Sparkles } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import {
+ createContractTemplateModalController,
+ createModalDialogHandlers,
+ initialTemplateFormData,
+ interpolateContractPreview,
+ TOKEN_CATEGORIES,
+ SAMPLE_COMMISSION_DATA,
+ SAMPLE_COMMISSION_DATA_DE,
+ type ContractTemplateFormData,
+ type ContractTemplateFormErrors,
+} from './contracts-helpers';
+import type { ContractTemplate } from '@/server/db/schema/contracts';
+
+export interface ContractTemplateModalProps {
+ template: ContractTemplate | null;
+ isOpen: boolean;
+ isSubmitting: boolean;
+ triggerRef?: React.RefObject
;
+ isDuplicate?: boolean;
+ initialErrors?: ContractTemplateFormErrors;
+ initialClauseTab?: 'en' | 'de';
+ onClose: () => void;
+ onSave: (payload: ContractTemplateFormData) => Promise;
+}
+
+const PROJECT_TYPE_OPTIONS = [
+ { value: '', labelKey: 'modal.projectTypeNone' },
+ { value: 'wedding', labelKey: 'list.projectTypes.wedding' },
+ { value: 'portrait', labelKey: 'list.projectTypes.portrait' },
+ { value: 'commercial', labelKey: 'list.projectTypes.commercial' },
+ { value: 'event', labelKey: 'list.projectTypes.event' },
+ { value: 'video', labelKey: 'list.projectTypes.video' },
+ { value: 'engagement', labelKey: 'list.projectTypes.engagement' },
+ { value: 'editorial', labelKey: 'list.projectTypes.editorial' },
+];
+
+interface ContractTemplateModalFormProps {
+ template: ContractTemplate | null;
+ isSubmitting: boolean;
+ isDuplicate: boolean;
+ initialErrors?: ContractTemplateFormErrors;
+ initialClauseTab?: 'en' | 'de';
+ onClose: () => void;
+ onSave: (payload: ContractTemplateFormData) => Promise;
+}
+
+function ContractTemplateModalForm({
+ template,
+ isSubmitting,
+ isDuplicate,
+ initialErrors,
+ initialClauseTab = 'en',
+ onClose,
+ onSave,
+}: ContractTemplateModalFormProps) {
+ const t = useTranslations('settings.contracts');
+
+ const [formData, setFormData] = React.useState(() =>
+ initialTemplateFormData(template, isDuplicate),
+ );
+ const [errors, setErrors] = React.useState(initialErrors ?? {});
+ const [activeClauseTab, setActiveClauseTab] = React.useState<'en' | 'de'>(initialClauseTab);
+ const [announcement, setAnnouncement] = React.useState('');
+
+ const controller = createContractTemplateModalController({
+ formData,
+ setFormData,
+ setErrors,
+ activeClauseTab,
+ setActiveClauseTab,
+ setLiveAnnouncement: setAnnouncement,
+ onClose,
+ onSave,
+ t,
+ });
+
+ const activeText = activeClauseTab === 'en' ? formData.contentEn : formData.contentDe;
+ const sampleData = activeClauseTab === 'de' ? SAMPLE_COMMISSION_DATA_DE : SAMPLE_COMMISSION_DATA;
+ const previewText = interpolateContractPreview(activeText, sampleData);
+
+ const modalTitle = isDuplicate
+ ? t('modal.duplicateTitle')
+ : template
+ ? t('modal.editTitle')
+ : t('modal.createTitle');
+
+ return (
+ <>
+
+ {modalTitle}
+
+ {t('modal.description')}
+
+
+
+ {/* Screen Reader Live Region for Token Insertion */}
+
+ {announcement}
+
+
+
+ >
+ );
+}
+
+export function ContractTemplateModal({
+ template,
+ isOpen,
+ isSubmitting,
+ triggerRef,
+ isDuplicate = false,
+ initialErrors,
+ initialClauseTab,
+ onClose,
+ onSave,
+}: ContractTemplateModalProps) {
+ const dialogHandlers = createModalDialogHandlers(onClose, triggerRef);
+
+ return (
+
+ );
+}
diff --git a/src/components/settings/contract-templates-list.tsx b/src/components/settings/contract-templates-list.tsx
new file mode 100644
index 0000000..3c84056
--- /dev/null
+++ b/src/components/settings/contract-templates-list.tsx
@@ -0,0 +1,391 @@
+'use client';
+
+import * as React from 'react';
+import { useTranslations } from 'next-intl';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Plus, Pencil, Copy, Archive, RotateCcw, Search, FileCheck2 } from 'lucide-react';
+import {
+ filterContractTemplates,
+ createContractTemplatesListController,
+ getProjectTypeLabelKey,
+} from './contracts-helpers';
+import type { ContractTemplate } from '@/server/db/schema/contracts';
+
+export interface ContractTemplatesListProps {
+ templates: ContractTemplate[];
+ onNewTemplate: () => void;
+ onEditTemplate: (template: ContractTemplate) => void;
+ onDuplicateTemplate: (template: ContractTemplate) => void;
+ onArchiveTemplate: (template: ContractTemplate) => Promise;
+ onRestoreTemplate: (template: ContractTemplate) => Promise;
+ initialProjectTypeKey?: string;
+ initialShowArchived?: boolean;
+ initialSearchQuery?: string;
+ initialTemplateToArchive?: ContractTemplate | null;
+}
+
+const PROJECT_TYPE_FILTER_OPTIONS = [
+ { value: '', labelKey: 'list.filterAll' },
+ { value: 'wedding', labelKey: 'list.projectTypes.wedding' },
+ { value: 'portrait', labelKey: 'list.projectTypes.portrait' },
+ { value: 'commercial', labelKey: 'list.projectTypes.commercial' },
+ { value: 'event', labelKey: 'list.projectTypes.event' },
+ { value: 'video', labelKey: 'list.projectTypes.video' },
+ { value: 'engagement', labelKey: 'list.projectTypes.engagement' },
+ { value: 'editorial', labelKey: 'list.projectTypes.editorial' },
+] as const;
+
+export function ContractTemplatesList({
+ templates,
+ onNewTemplate,
+ onEditTemplate,
+ onDuplicateTemplate,
+ onArchiveTemplate,
+ onRestoreTemplate,
+ initialProjectTypeKey = '',
+ initialShowArchived = false,
+ initialSearchQuery = '',
+ initialTemplateToArchive = null,
+}: ContractTemplatesListProps) {
+ const t = useTranslations('settings.contracts');
+
+ const [projectTypeKey, setProjectTypeKey] = React.useState(initialProjectTypeKey);
+ const [showArchived, setShowArchived] = React.useState(initialShowArchived);
+ const [searchQuery, setSearchQuery] = React.useState(initialSearchQuery);
+
+ // Archive confirmation modal state
+ const [templateToArchive, setTemplateToArchive] = React.useState(
+ initialTemplateToArchive,
+ );
+ const [isArchiving, setIsArchiving] = React.useState(false);
+
+ const controller = createContractTemplatesListController({
+ templates,
+ onEditTemplate,
+ onDuplicateTemplate,
+ setProjectTypeKey,
+ setSearchQuery,
+ showArchived,
+ setShowArchived,
+ templateToArchive,
+ setTemplateToArchive,
+ setIsArchiving,
+ onArchiveTemplate,
+ onRestoreTemplate,
+ });
+
+ const archivedCount = templates.filter((t) => !t.isActive).length;
+
+ const filtered = filterContractTemplates(templates, {
+ projectTypeKey,
+ showArchived,
+ searchQuery,
+ });
+
+ return (
+
+ {/* List Header & Action Button */}
+
+
+
+ {t('list.title')}
+
+
{t('list.description')}
+
+
+
+
+ {/* Filter and Search Bar */}
+
+
+ {/* Project Type Filter */}
+
+
+
+
+ {/* Search Input */}
+
+
+
+
+
+
+ {/* Toggle Archived */}
+
+
+
+
+
+ {/* Templates List Table / Cards */}
+ {filtered.length === 0 ? (
+
+
+
+ {showArchived ? t('list.emptyArchived') : t('list.emptyActive')}
+
+
+ ) : (
+
+
+
+
+ |
+ {t('list.table.name')}
+ |
+
+ {t('list.table.slug')}
+ |
+
+ {t('list.table.projectType')}
+ |
+
+ {t('list.table.status')}
+ |
+
+ {t('list.table.actions')}
+ |
+
+
+
+ {filtered.map((item) => (
+
+ {/* Name & Description & Default Badge */}
+ |
+
+
+ {item.name}
+
+ {item.isDefault && (
+
+ {t('list.defaultBadge')}
+
+ )}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+ |
+
+ {/* Slug */}
+
+
+ {item.slug}
+
+ |
+
+ {/* Project Type */}
+
+
+ {t(getProjectTypeLabelKey(item.projectTypeKey))}
+
+ |
+
+ {/* Status */}
+
+ {item.isActive ? (
+
+ {t('list.activeBadge')}
+
+ ) : (
+
+ {t('list.archivedBadge')}
+
+ )}
+ |
+
+ {/* Quick Actions */}
+
+
+ {/* Edit */}
+
+
+ {/* Duplicate */}
+
+
+ {/* Archive / Restore */}
+ {item.isActive ? (
+
+ ) : (
+
+ )}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* Archive Confirmation Dialog */}
+
+
+ );
+}
diff --git a/src/components/settings/contracts-helpers.ts b/src/components/settings/contracts-helpers.ts
new file mode 100644
index 0000000..e1f6556
--- /dev/null
+++ b/src/components/settings/contracts-helpers.ts
@@ -0,0 +1,1140 @@
+import type * as React from 'react';
+import type { ContractTemplate } from '@/server/db/schema/contracts';
+import type {
+ ContractCountersignatureMode,
+ StudioSettings,
+} from '@/server/db/schema/studio-settings';
+import type {
+ CreateContractTemplateInput,
+ UpdateContractTemplateActionInput,
+ ArchiveContractTemplateActionInput,
+ UpdateStudioSigningProfileInput,
+ ContractActionResult,
+} from '@/server/contracts/actions';
+
+/**
+ * Realistic synthetic commission data for live template merge preview (AC-3).
+ * Fixed synthetic values (no real personal data).
+ */
+export const SAMPLE_COMMISSION_DATA: Record = {
+ // client namespace
+ 'client.name': 'Sarah & Michael Jenkins',
+ 'client.company': 'Jenkins Design Studio',
+ 'client.email': 'sarah.jenkins@example.com',
+ 'client.phone': '+49 30 12345678',
+ 'client.address': 'Musterstraße 42',
+ 'client.city': 'Berlin',
+ 'client.postal_code': '10115',
+ 'client.country': 'Germany',
+
+ // project namespace
+ 'project.title': 'Jenkins Wedding & Celebration',
+ 'project.type': 'Wedding',
+ 'project.price': '€2,800.00',
+ 'project.currency': 'EUR',
+ 'project.notes': 'Full-day coverage including ceremony, reception, and couple portraits',
+
+ // shoot namespace
+ 'shoot.date': 'August 15, 2026',
+ 'shoot.time': '14:00 - 22:00',
+ 'shoot.timezone': 'Europe/Berlin',
+ 'shoot.location': 'Schloss Charlottenburg Orangery',
+ 'shoot.address': 'Spandauer Damm 10-22, 14059 Berlin',
+
+ // package namespace
+ 'package.name': 'Full Day Wedding Collection',
+ 'package.deliverables': '8 Hours Coverage, 400+ Edited High-Res Images, Online Gallery',
+ 'package.total': '€2,800.00',
+ 'package.subtotal': '€2,352.94',
+ 'package.tax': '€447.06',
+ 'package.addons': 'Handcrafted Fine Art Album (30x30cm)',
+
+ // studio namespace
+ 'studio.name': 'Lumière Photography Studio',
+ 'studio.owner_name': 'Elena Rostova',
+ 'studio.email': 'hello@lumiere-studio.example.com',
+ 'studio.phone': '+49 30 98765432',
+ 'studio.address': 'Kastanienallee 71, 10435 Berlin',
+ 'studio.tax_number': '34/567/89012',
+ 'studio.vat_id': 'DE123456789',
+
+ // contract namespace
+ 'contract.number': 'CTR-2026-0042',
+ 'contract.issue_date': 'June 1, 2026',
+ 'contract.valid_until': 'June 15, 2026',
+};
+
+/**
+ * German synthetic commission data for German live merge preview.
+ */
+export const SAMPLE_COMMISSION_DATA_DE: Record = {
+ ...SAMPLE_COMMISSION_DATA,
+ 'shoot.date': '15. August 2026',
+ 'contract.issue_date': '1. Juni 2026',
+ 'contract.valid_until': '15. Juni 2026',
+ 'project.type': 'Hochzeit',
+ 'client.country': 'Deutschland',
+};
+
+export interface TokenItem {
+ token: string;
+ chipText: string;
+ labelKey: string;
+}
+
+export interface TokenCategory {
+ id: string;
+ nameKey: string;
+ tokens: TokenItem[];
+}
+
+/**
+ * Categorized merge tokens palette definition.
+ */
+export const TOKEN_CATEGORIES: TokenCategory[] = [
+ {
+ id: 'client',
+ nameKey: 'tokens.categories.client',
+ tokens: [
+ { token: 'client.name', chipText: '{{client.name}}', labelKey: 'tokens.client.name' },
+ {
+ token: 'client.company',
+ chipText: '{{client.company}}',
+ labelKey: 'tokens.client.company',
+ },
+ { token: 'client.email', chipText: '{{client.email}}', labelKey: 'tokens.client.email' },
+ { token: 'client.phone', chipText: '{{client.phone}}', labelKey: 'tokens.client.phone' },
+ {
+ token: 'client.address',
+ chipText: '{{client.address}}',
+ labelKey: 'tokens.client.address',
+ },
+ { token: 'client.city', chipText: '{{client.city}}', labelKey: 'tokens.client.city' },
+ {
+ token: 'client.postal_code',
+ chipText: '{{client.postal_code}}',
+ labelKey: 'tokens.client.postal_code',
+ },
+ {
+ token: 'client.country',
+ chipText: '{{client.country}}',
+ labelKey: 'tokens.client.country',
+ },
+ ],
+ },
+ {
+ id: 'project',
+ nameKey: 'tokens.categories.project',
+ tokens: [
+ { token: 'project.title', chipText: '{{project.title}}', labelKey: 'tokens.project.title' },
+ { token: 'project.type', chipText: '{{project.type}}', labelKey: 'tokens.project.type' },
+ { token: 'project.price', chipText: '{{project.price}}', labelKey: 'tokens.project.price' },
+ {
+ token: 'project.currency',
+ chipText: '{{project.currency}}',
+ labelKey: 'tokens.project.currency',
+ },
+ { token: 'project.notes', chipText: '{{project.notes}}', labelKey: 'tokens.project.notes' },
+ ],
+ },
+ {
+ id: 'shoot',
+ nameKey: 'tokens.categories.shoot',
+ tokens: [
+ { token: 'shoot.date', chipText: '{{shoot.date}}', labelKey: 'tokens.shoot.date' },
+ { token: 'shoot.time', chipText: '{{shoot.time}}', labelKey: 'tokens.shoot.time' },
+ {
+ token: 'shoot.timezone',
+ chipText: '{{shoot.timezone}}',
+ labelKey: 'tokens.shoot.timezone',
+ },
+ {
+ token: 'shoot.location',
+ chipText: '{{shoot.location}}',
+ labelKey: 'tokens.shoot.location',
+ },
+ { token: 'shoot.address', chipText: '{{shoot.address}}', labelKey: 'tokens.shoot.address' },
+ ],
+ },
+ {
+ id: 'package',
+ nameKey: 'tokens.categories.package',
+ tokens: [
+ { token: 'package.name', chipText: '{{package.name}}', labelKey: 'tokens.package.name' },
+ {
+ token: 'package.deliverables',
+ chipText: '{{package.deliverables}}',
+ labelKey: 'tokens.package.deliverables',
+ },
+ { token: 'package.total', chipText: '{{package.total}}', labelKey: 'tokens.package.total' },
+ {
+ token: 'package.subtotal',
+ chipText: '{{package.subtotal}}',
+ labelKey: 'tokens.package.subtotal',
+ },
+ { token: 'package.tax', chipText: '{{package.tax}}', labelKey: 'tokens.package.tax' },
+ {
+ token: 'package.addons',
+ chipText: '{{package.addons}}',
+ labelKey: 'tokens.package.addons',
+ },
+ ],
+ },
+ {
+ id: 'studio',
+ nameKey: 'tokens.categories.studio',
+ tokens: [
+ { token: 'studio.name', chipText: '{{studio.name}}', labelKey: 'tokens.studio.name' },
+ {
+ token: 'studio.owner_name',
+ chipText: '{{studio.owner_name}}',
+ labelKey: 'tokens.studio.owner_name',
+ },
+ { token: 'studio.email', chipText: '{{studio.email}}', labelKey: 'tokens.studio.email' },
+ { token: 'studio.phone', chipText: '{{studio.phone}}', labelKey: 'tokens.studio.phone' },
+ {
+ token: 'studio.address',
+ chipText: '{{studio.address}}',
+ labelKey: 'tokens.studio.address',
+ },
+ {
+ token: 'studio.tax_number',
+ chipText: '{{studio.tax_number}}',
+ labelKey: 'tokens.studio.tax_number',
+ },
+ { token: 'studio.vat_id', chipText: '{{studio.vat_id}}', labelKey: 'tokens.studio.vat_id' },
+ ],
+ },
+ {
+ id: 'contract',
+ nameKey: 'tokens.categories.contract',
+ tokens: [
+ {
+ token: 'contract.number',
+ chipText: '{{contract.number}}',
+ labelKey: 'tokens.contract.number',
+ },
+ {
+ token: 'contract.issue_date',
+ chipText: '{{contract.issue_date}}',
+ labelKey: 'tokens.contract.issue_date',
+ },
+ {
+ token: 'contract.valid_until',
+ chipText: '{{contract.valid_until}}',
+ labelKey: 'tokens.contract.valid_until',
+ },
+ ],
+ },
+];
+
+/**
+ * Automatically converts human-readable template name into kebab-case slug.
+ */
+export function generateTemplateSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '') // strip diacritics
+ .replace(/[^a-z0-9]+/g, '-') // non-alphanumeric to hyphens
+ .replace(/^-+|-+$/g, ''); // strip leading/trailing hyphens
+}
+
+/**
+ * Inserts token chip into text at current cursor/selection and computes new cursor offset.
+ */
+export function insertTokenAtCursor(
+ text: string,
+ token: string,
+ selectionStart?: number,
+ selectionEnd?: number,
+): { newText: string; newCursorPos: number } {
+ const tokenFormatted = `{{${token}}}`;
+ const start = selectionStart ?? text.length;
+ const end = selectionEnd ?? text.length;
+
+ const before = text.slice(0, start);
+ const after = text.slice(end);
+
+ const newText = `${before}${tokenFormatted}${after}`;
+ const newCursorPos = start + tokenFormatted.length;
+
+ return { newText, newCursorPos };
+}
+
+/**
+ * Interpolates template text against synthetic sample commission data for live preview (AC-3).
+ * Sanitizes input and clears unmapped tokens safely.
+ */
+export function interpolateContractPreview(
+ templateText: string,
+ sampleData: Record = SAMPLE_COMMISSION_DATA,
+): string {
+ if (!templateText) {
+ return '';
+ }
+
+ const sampleMap = new Map(Object.entries(sampleData));
+
+ return templateText.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, rawToken: string) => {
+ const token = rawToken.trim();
+ return sampleMap.get(token) ?? '';
+ });
+}
+
+export interface ContractTemplateFilterOptions {
+ projectTypeKey?: string;
+ showArchived?: boolean;
+ searchQuery?: string;
+}
+
+/**
+ * Filters contract templates by project type, active/archived state, and search query.
+ */
+export function filterContractTemplates(
+ templates: ContractTemplate[],
+ options: ContractTemplateFilterOptions = {},
+): ContractTemplate[] {
+ const { projectTypeKey = '', showArchived = false, searchQuery = '' } = options;
+ const query = searchQuery.trim().toLowerCase();
+
+ return templates
+ .filter((template) => {
+ // 1. Active vs Archived filter
+ if (!showArchived && !template.isActive) {
+ return false;
+ }
+ if (showArchived && template.isActive) {
+ return false;
+ }
+
+ // 2. Project type key filter
+ if (projectTypeKey && template.projectTypeKey !== projectTypeKey) {
+ return false;
+ }
+
+ // 3. Search query filter
+ if (query) {
+ const matchesName = template.name.toLowerCase().includes(query);
+ const matchesSlug = template.slug.toLowerCase().includes(query);
+ const matchesDesc = (template.description ?? '').toLowerCase().includes(query);
+ if (!matchesName && !matchesSlug && !matchesDesc) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .sort((a, b) => {
+ // Default templates first, then alphabetical by name
+ if (a.isDefault && !b.isDefault) return -1;
+ if (!a.isDefault && b.isDefault) return 1;
+ return a.name.localeCompare(b.name);
+ });
+}
+
+export interface ContractTemplateFormData {
+ id?: string;
+ name: string;
+ slug: string;
+ description: string;
+ projectTypeKey: string;
+ contentEn: string;
+ contentDe: string;
+ isDefault: boolean;
+ isActive: boolean;
+}
+
+export type ContractTemplateFormErrors = Record;
+
+/**
+ * Validates contract template modal form inputs with localized error messages.
+ */
+export function validateContractTemplateForm(
+ data: ContractTemplateFormData,
+ t: (key: string) => string,
+): ContractTemplateFormErrors {
+ const errors: ContractTemplateFormErrors = {};
+
+ const trimmedName = data.name.trim();
+ if (!trimmedName) {
+ errors.name = t('modal.errors.nameRequired');
+ } else if (trimmedName.length > 150) {
+ errors.name = t('modal.errors.nameTooLong');
+ }
+
+ const trimmedSlug = data.slug.trim();
+ const isValidSlug =
+ /^[a-z0-9-]+$/.test(trimmedSlug) &&
+ !trimmedSlug.startsWith('-') &&
+ !trimmedSlug.endsWith('-') &&
+ !trimmedSlug.includes('--');
+ if (!trimmedSlug) {
+ errors.slug = t('modal.errors.slugRequired');
+ } else if (!isValidSlug) {
+ errors.slug = t('modal.errors.slugInvalid');
+ } else if (trimmedSlug.length > 100) {
+ errors.slug = t('modal.errors.slugTooLong');
+ }
+
+ if (data.description && data.description.length > 1000) {
+ errors.description = t('modal.errors.descriptionTooLong');
+ }
+
+ if (!data.contentEn.trim()) {
+ errors.contentEn = t('modal.errors.contentEnRequired');
+ }
+
+ if (!data.contentDe.trim()) {
+ errors.contentDe = t('modal.errors.contentDeRequired');
+ }
+
+ return errors;
+}
+
+export function initialTemplateFormData(
+ template?: ContractTemplate | null,
+ isDuplicate?: boolean,
+): ContractTemplateFormData {
+ if (template) {
+ if (isDuplicate) {
+ return {
+ id: undefined,
+ name: `${template.name} (Copy)`,
+ slug: `${template.slug}-copy`,
+ description: template.description ?? '',
+ projectTypeKey: template.projectTypeKey ?? '',
+ isDefault: false,
+ contentEn: template.contentEn,
+ contentDe: template.contentDe,
+ isActive: true,
+ };
+ }
+ return {
+ id: template.id,
+ name: template.name,
+ slug: template.slug,
+ description: template.description ?? '',
+ projectTypeKey: template.projectTypeKey ?? '',
+ isDefault: template.isDefault,
+ contentEn: template.contentEn,
+ contentDe: template.contentDe,
+ isActive: template.isActive,
+ };
+ }
+ return {
+ name: '',
+ slug: '',
+ description: '',
+ projectTypeKey: '',
+ isDefault: false,
+ contentEn: '',
+ contentDe: '',
+ isActive: true,
+ };
+}
+
+export interface StudioSigningFormData {
+ contractSignatoryName: string;
+ contractSignatoryTitle: string;
+ contractCountersignatureMode: ContractCountersignatureMode;
+ studioAddressLine1: string;
+ studioAddressLine2: string;
+ studioPostalCode: string;
+ studioCity: string;
+ studioCountryCode: string;
+ studioPhone: string;
+ studioEmail: string;
+}
+
+export type StudioSigningFormErrors = Record;
+
+/**
+ * Validates studio signing profile form fields.
+ */
+export function validateStudioSigningForm(
+ data: StudioSigningFormData,
+ t: (key: string) => string,
+): StudioSigningFormErrors {
+ const errors: StudioSigningFormErrors = {};
+
+ if (data.contractSignatoryName && data.contractSignatoryName.trim().length > 200) {
+ errors.contractSignatoryName = t('signing.errors.signatoryNameTooLong');
+ }
+
+ if (data.contractSignatoryTitle && data.contractSignatoryTitle.trim().length > 100) {
+ errors.contractSignatoryTitle = t('signing.errors.signatoryTitleTooLong');
+ }
+
+ if (data.studioCountryCode) {
+ const code = data.studioCountryCode.trim().toUpperCase();
+ if (code.length !== 2) {
+ errors.studioCountryCode = t('signing.errors.countryCodeInvalid');
+ }
+ }
+
+ if (data.studioEmail.trim()) {
+ const email = data.studioEmail.trim();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ errors.studioEmail = t('signing.errors.emailInvalid');
+ }
+ }
+
+ return errors;
+}
+
+export const EMPTY_TEMPLATE_FORM_DATA: ContractTemplateFormData = {
+ name: '',
+ slug: '',
+ description: '',
+ projectTypeKey: '',
+ contentEn: '',
+ contentDe: '',
+ isDefault: false,
+ isActive: true,
+};
+
+export const EMPTY_SIGNING_FORM_DATA: StudioSigningFormData = {
+ contractSignatoryName: '',
+ contractSignatoryTitle: '',
+ contractCountersignatureMode: 'pre_signed',
+ studioAddressLine1: '',
+ studioAddressLine2: '',
+ studioPostalCode: '',
+ studioCity: '',
+ studioCountryCode: 'DE',
+ studioPhone: '',
+ studioEmail: '',
+};
+
+export function initialSigningFormDataFromSettings(
+ settings?: StudioSettings | null,
+): StudioSigningFormData {
+ if (!settings) {
+ return { ...EMPTY_SIGNING_FORM_DATA };
+ }
+
+ return {
+ contractSignatoryName: settings.contract_signatory_name ?? '',
+ contractSignatoryTitle: settings.contract_signatory_title ?? '',
+ contractCountersignatureMode: settings.contract_countersignature_mode,
+ studioAddressLine1: settings.studio_address_line1 ?? '',
+ studioAddressLine2: settings.studio_address_line2 ?? '',
+ studioPostalCode: settings.studio_postal_code ?? '',
+ studioCity: settings.studio_city ?? '',
+ studioCountryCode: settings.studio_country_code,
+ studioPhone: settings.studio_phone ?? '',
+ studioEmail: settings.studio_email ?? '',
+ };
+}
+
+const PROJECT_TYPE_MAP = new Map([
+ ['wedding', 'list.projectTypes.wedding'],
+ ['portrait', 'list.projectTypes.portrait'],
+ ['commercial', 'list.projectTypes.commercial'],
+ ['event', 'list.projectTypes.event'],
+ ['video', 'list.projectTypes.video'],
+ ['engagement', 'list.projectTypes.engagement'],
+ ['editorial', 'list.projectTypes.editorial'],
+]);
+
+export const PROJECT_TYPE_LABELS: Record = Object.fromEntries(PROJECT_TYPE_MAP);
+
+export function getProjectTypeLabelKey(projectTypeKey: string | null | undefined): string {
+ return (
+ (projectTypeKey ? PROJECT_TYPE_MAP.get(projectTypeKey) : undefined) ?? 'list.projectTypeNone'
+ );
+}
+
+export function createModalDialogHandlers(
+ onClose: () => void,
+ triggerRef?: React.RefObject,
+) {
+ return {
+ onOpenChange: (open: boolean) => {
+ if (!open) {
+ onClose();
+ }
+ },
+ onCloseAutoFocus: (e: Event) => {
+ e.preventDefault();
+ triggerRef?.current?.focus();
+ },
+ };
+}
+
+export type TranslationFunction = (
+ key: string,
+ values?: Record,
+) => string;
+
+export interface StudioSigningControllerParams {
+ formData: StudioSigningFormData;
+ setFormData: React.Dispatch>;
+ setErrors: React.Dispatch>;
+ setIsSubmitting: (val: boolean) => void;
+ setShowSuccess: (val: boolean) => void;
+ onSave: (payload: StudioSigningFormData) => Promise;
+ t: TranslationFunction;
+}
+
+export function createStudioSigningController(params: StudioSigningControllerParams) {
+ const { formData, setFormData, setErrors, setIsSubmitting, setShowSuccess, onSave, t } = params;
+
+ const handleFieldChange = (field: keyof StudioSigningFormData, value: string) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+ setShowSuccess(false);
+ };
+
+ const handleSignatoryNameChange = (e: React.ChangeEvent) => {
+ handleFieldChange('contractSignatoryName', e.target.value);
+ };
+
+ const handleSignatoryTitleChange = (e: React.ChangeEvent) => {
+ handleFieldChange('contractSignatoryTitle', e.target.value);
+ };
+
+ const handleAddressLine1Change = (e: React.ChangeEvent) => {
+ handleFieldChange('studioAddressLine1', e.target.value);
+ };
+
+ const handleAddressLine2Change = (e: React.ChangeEvent) => {
+ handleFieldChange('studioAddressLine2', e.target.value);
+ };
+
+ const handlePostalCodeChange = (e: React.ChangeEvent) => {
+ handleFieldChange('studioPostalCode', e.target.value);
+ };
+
+ const handleCityChange = (e: React.ChangeEvent) => {
+ handleFieldChange('studioCity', e.target.value);
+ };
+
+ const handleCountryCodeChange = (e: React.ChangeEvent) => {
+ handleFieldChange('studioCountryCode', e.target.value);
+ };
+
+ const handlePhoneChange = (e: React.ChangeEvent) => {
+ handleFieldChange('studioPhone', e.target.value);
+ };
+
+ const handleEmailChange = (e: React.ChangeEvent) => {
+ handleFieldChange('studioEmail', e.target.value);
+ };
+
+ const handleSelectPreSigned = () => {
+ setFormData((prev) => ({ ...prev, contractCountersignatureMode: 'pre_signed' }));
+ setShowSuccess(false);
+ };
+
+ const handleSelectExplicit = () => {
+ setFormData((prev) => ({ ...prev, contractCountersignatureMode: 'explicit' }));
+ setShowSuccess(false);
+ };
+
+ const handleSubmit = (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ void (async () => {
+ const validationErrors = validateStudioSigningForm(formData, t);
+ if (Object.keys(validationErrors).length > 0) {
+ setErrors(validationErrors);
+ return;
+ }
+ setErrors({});
+ setIsSubmitting(true);
+ try {
+ const success = await onSave(formData);
+ if (success) {
+ setShowSuccess(true);
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ })();
+ };
+
+ return {
+ handleFieldChange,
+ handleSignatoryNameChange,
+ handleSignatoryTitleChange,
+ handleAddressLine1Change,
+ handleAddressLine2Change,
+ handlePostalCodeChange,
+ handleCityChange,
+ handleCountryCodeChange,
+ handlePhoneChange,
+ handleEmailChange,
+ handleSelectPreSigned,
+ handleSelectExplicit,
+ handleSubmit,
+ };
+}
+
+export interface ContractTemplateModalControllerParams {
+ formData: ContractTemplateFormData;
+ setFormData: React.Dispatch>;
+ setErrors: React.Dispatch>;
+ activeClauseTab: 'en' | 'de';
+ setActiveClauseTab: (tab: 'en' | 'de') => void;
+ setLiveAnnouncement: (msg: string) => void;
+ onClose: () => void;
+ onSave: (payload: ContractTemplateFormData) => Promise;
+ t: TranslationFunction;
+}
+
+export function createContractTemplateModalController(
+ params: ContractTemplateModalControllerParams,
+) {
+ const {
+ formData,
+ setFormData,
+ setErrors,
+ activeClauseTab,
+ setActiveClauseTab,
+ setLiveAnnouncement,
+ onClose,
+ onSave,
+ t,
+ } = params;
+
+ const handleFieldChange = (field: keyof ContractTemplateFormData, value: unknown) => {
+ setFormData((prev) => {
+ const updated = { ...prev, [field]: value };
+ if (field === 'name' && (!prev.id || prev.slug === '')) {
+ updated.slug = generateTemplateSlug(String(value));
+ }
+ return updated;
+ });
+ setErrors((prev) => {
+ const errorKey = field as keyof ContractTemplateFormErrors;
+ if (!(errorKey in prev)) return prev;
+ const next: ContractTemplateFormErrors = { ...prev };
+ Reflect.deleteProperty(next, errorKey);
+ return next;
+ });
+ };
+
+ const handleNameChange = (e: React.ChangeEvent) => {
+ handleFieldChange('name', e.target.value);
+ };
+
+ const handleSlugChange = (e: React.ChangeEvent) => {
+ handleFieldChange('slug', e.target.value);
+ };
+
+ const handleDescriptionChange = (
+ e: React.ChangeEvent,
+ ) => {
+ handleFieldChange('description', e.target.value);
+ };
+
+ const handleProjectTypeChange = (e: React.ChangeEvent) => {
+ handleFieldChange('projectTypeKey', e.target.value);
+ };
+
+ const handleDefaultChange = (e: React.ChangeEvent) => {
+ handleFieldChange('isDefault', e.target.checked);
+ };
+
+ const handleContentEnChange = (e: React.ChangeEvent) => {
+ handleFieldChange('contentEn', e.target.value);
+ };
+
+ const handleContentDeChange = (e: React.ChangeEvent) => {
+ handleFieldChange('contentDe', e.target.value);
+ };
+
+ const handleSelectEnClauseTab = () => {
+ setActiveClauseTab('en');
+ };
+ const handleSelectDeClauseTab = () => {
+ setActiveClauseTab('de');
+ };
+
+ const handleInsertToken = (token: string) => {
+ const textarea =
+ typeof document !== 'undefined'
+ ? (document.getElementById(
+ activeClauseTab === 'en' ? 'content-en-textarea' : 'content-de-textarea',
+ ) as HTMLTextAreaElement | null)
+ : null;
+ const currentText = activeClauseTab === 'en' ? formData.contentEn : formData.contentDe;
+
+ const start = textarea?.selectionStart ?? currentText.length;
+ const end = textarea?.selectionEnd ?? currentText.length;
+
+ const { newText, newCursorPos } = insertTokenAtCursor(currentText, token, start, end);
+
+ if (activeClauseTab === 'en') {
+ handleFieldChange('contentEn', newText);
+ } else {
+ handleFieldChange('contentDe', newText);
+ }
+
+ setLiveAnnouncement(t('modal.tokenInsertedAnnouncement', { token }));
+
+ if (textarea) {
+ setTimeout(() => {
+ textarea.focus();
+ textarea.setSelectionRange(newCursorPos, newCursorPos);
+ }, 0);
+ }
+ };
+
+ const handleTokenButtonClick = (e: React.MouseEvent) => {
+ const token = e.currentTarget.getAttribute('data-token');
+ if (token) {
+ handleInsertToken(token);
+ }
+ };
+
+ const handleSubmit = (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ void (async () => {
+ const validationErrors = validateContractTemplateForm(formData, t);
+ if (Object.keys(validationErrors).length > 0) {
+ setErrors(validationErrors);
+ return;
+ }
+ setErrors({});
+ try {
+ const success = await onSave(formData);
+ if (success) {
+ onClose();
+ }
+ } catch {
+ // Handled by onSave
+ }
+ })();
+ };
+
+ return {
+ handleFieldChange,
+ handleNameChange,
+ handleSlugChange,
+ handleDescriptionChange,
+ handleProjectTypeChange,
+ handleDefaultChange,
+ handleContentEnChange,
+ handleContentDeChange,
+ handleSelectEnClauseTab,
+ handleSelectDeClauseTab,
+ handleInsertToken,
+ handleTokenButtonClick,
+ handleSubmit,
+ };
+}
+
+export interface ContractTemplatesListControllerParams {
+ templates?: ContractTemplate[];
+ onEditTemplate?: (template: ContractTemplate) => void;
+ onDuplicateTemplate?: (template: ContractTemplate) => void;
+ setProjectTypeKey: (val: string) => void;
+ setSearchQuery: (val: string) => void;
+ showArchived: boolean;
+ setShowArchived: React.Dispatch>;
+ templateToArchive?: ContractTemplate | null;
+ setTemplateToArchive: (val: ContractTemplate | null) => void;
+ setIsArchiving: (val: boolean) => void;
+ onArchiveTemplate: (template: ContractTemplate) => Promise;
+ onRestoreTemplate: (template: ContractTemplate) => Promise;
+}
+
+export function createContractTemplatesListController(
+ params: ContractTemplatesListControllerParams,
+) {
+ const {
+ templates = [],
+ onEditTemplate,
+ onDuplicateTemplate,
+ setProjectTypeKey,
+ setSearchQuery,
+ showArchived,
+ setShowArchived,
+ templateToArchive = null,
+ setTemplateToArchive,
+ setIsArchiving,
+ onArchiveTemplate,
+ onRestoreTemplate,
+ } = params;
+
+ const handleProjectTypeChange = (e: React.ChangeEvent) => {
+ setProjectTypeKey(e.target.value);
+ };
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ setSearchQuery(e.target.value);
+ };
+
+ const handleToggleArchived = () => {
+ setShowArchived(!showArchived);
+ };
+
+ const handleOpenArchiveDialog = (template: ContractTemplate) => {
+ setTemplateToArchive(template);
+ };
+
+ const handleCloseArchiveDialog = () => {
+ setTemplateToArchive(null);
+ };
+
+ const handleConfirmArchiveOrRestore = async (targetTemplate: ContractTemplate | null) => {
+ if (!targetTemplate) return;
+ setIsArchiving(true);
+ try {
+ const success = targetTemplate.isActive
+ ? await onArchiveTemplate(targetTemplate)
+ : await onRestoreTemplate(targetTemplate);
+ if (success) {
+ setTemplateToArchive(null);
+ }
+ } finally {
+ setIsArchiving(false);
+ }
+ };
+
+ const handleEditRowClick = (e: React.MouseEvent) => {
+ const slug = e.currentTarget.getAttribute('data-slug');
+ const target = templates.find((t) => t.slug === slug);
+ if (target && onEditTemplate) onEditTemplate(target);
+ };
+
+ const handleDuplicateRowClick = (e: React.MouseEvent) => {
+ const slug = e.currentTarget.getAttribute('data-slug');
+ const target = templates.find((t) => t.slug === slug);
+ if (target && onDuplicateTemplate) onDuplicateTemplate(target);
+ };
+
+ const handleArchiveRowClick = (e: React.MouseEvent) => {
+ const slug = e.currentTarget.getAttribute('data-slug');
+ const target = templates.find((t) => t.slug === slug);
+ if (target) handleOpenArchiveDialog(target);
+ };
+
+ const handleRestoreRowClick = (e: React.MouseEvent) => {
+ const slug = e.currentTarget.getAttribute('data-slug');
+ const target = templates.find((t) => t.slug === slug);
+ if (target) void onRestoreTemplate(target);
+ };
+
+ const handleArchiveDialogOpenChange = (open: boolean) => {
+ if (!open) {
+ handleCloseArchiveDialog();
+ }
+ };
+
+ const handleArchiveDialogCloseAutoFocus = (e: Event) => {
+ e.preventDefault();
+ if (typeof document !== 'undefined' && templateToArchive?.slug) {
+ const triggerBtn = document.querySelector(
+ `[data-testid="archive-template-${templateToArchive.slug}"]`,
+ );
+ triggerBtn?.focus();
+ }
+ };
+
+ const handleConfirmArchiveClick = () => {
+ void handleConfirmArchiveOrRestore(templateToArchive);
+ };
+
+ return {
+ handleProjectTypeChange,
+ handleSearchChange,
+ handleToggleArchived,
+ handleOpenArchiveDialog,
+ handleCloseArchiveDialog,
+ handleConfirmArchiveOrRestore,
+ handleEditRowClick,
+ handleDuplicateRowClick,
+ handleArchiveRowClick,
+ handleRestoreRowClick,
+ handleArchiveDialogOpenChange,
+ handleArchiveDialogCloseAutoFocus,
+ handleConfirmArchiveClick,
+ };
+}
+
+export interface ContractsSettingsViewControllerParams {
+ setActiveTab: (tab: 'templates' | 'signing') => void;
+ setEditingTemplate: (t: ContractTemplate | null) => void;
+ setIsDuplicate: (val: boolean) => void;
+ setIsModalOpen: (val: boolean) => void;
+ setIsSubmittingTemplate: (val: boolean) => void;
+ setTemplates: React.Dispatch>;
+ setSigningSettings: React.Dispatch>;
+ router: { refresh: () => void };
+ updateContractAction?: (
+ input: UpdateContractTemplateActionInput,
+ ) => Promise>;
+ createContractAction?: (
+ input: CreateContractTemplateInput,
+ ) => Promise>;
+ archiveContractAction?: (
+ input: ArchiveContractTemplateActionInput,
+ ) => Promise>;
+ updateSigningAction?: (
+ input: UpdateStudioSigningProfileInput,
+ ) => Promise>;
+}
+
+export function createContractsSettingsViewController(
+ params: ContractsSettingsViewControllerParams,
+) {
+ const {
+ setActiveTab,
+ setEditingTemplate,
+ setIsDuplicate,
+ setIsModalOpen,
+ setIsSubmittingTemplate,
+ setTemplates,
+ setSigningSettings,
+ router,
+ updateContractAction,
+ createContractAction,
+ archiveContractAction,
+ updateSigningAction,
+ } = params;
+
+ const handleSelectTemplatesTab = () => {
+ setActiveTab('templates');
+ };
+ const handleSelectSigningTab = () => {
+ setActiveTab('signing');
+ };
+
+ const handleTabKeyDown = (e: React.KeyboardEvent, currentTab: 'templates' | 'signing') => {
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
+ e.preventDefault();
+ const nextTab = currentTab === 'templates' ? 'signing' : 'templates';
+ setActiveTab(nextTab);
+ if (typeof document !== 'undefined') {
+ const nextTabEl = document.getElementById(`contracts-tab-${nextTab}`);
+ nextTabEl?.focus();
+ }
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ setActiveTab('templates');
+ if (typeof document !== 'undefined') {
+ document.getElementById('contracts-tab-templates')?.focus();
+ }
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ setActiveTab('signing');
+ if (typeof document !== 'undefined') {
+ document.getElementById('contracts-tab-signing')?.focus();
+ }
+ }
+ };
+
+ const handleNewTemplate = () => {
+ setEditingTemplate(null);
+ setIsDuplicate(false);
+ setIsModalOpen(true);
+ };
+
+ const handleEditTemplate = (template: ContractTemplate) => {
+ setEditingTemplate(template);
+ setIsDuplicate(false);
+ setIsModalOpen(true);
+ };
+
+ const handleDuplicateTemplate = (template: ContractTemplate) => {
+ setEditingTemplate(template);
+ setIsDuplicate(true);
+ setIsModalOpen(true);
+ };
+
+ const handleCloseModal = () => {
+ setIsModalOpen(false);
+ setEditingTemplate(null);
+ setIsDuplicate(false);
+ };
+
+ const handleSaveTemplate = async (data: ContractTemplateFormData): Promise => {
+ setIsSubmittingTemplate(true);
+ try {
+ if (data.id) {
+ if (!updateContractAction) return false;
+ const result = await updateContractAction({ ...data, id: data.id });
+ if (result.success) {
+ setTemplates((prev) =>
+ prev.map((item) => (item.id === result.data.id ? result.data : item)),
+ );
+ router.refresh();
+ return true;
+ }
+ } else {
+ if (!createContractAction) return false;
+ const result = await createContractAction(data);
+ if (result.success) {
+ setTemplates((prev) => [result.data, ...prev]);
+ router.refresh();
+ return true;
+ }
+ }
+ return false;
+ } finally {
+ setIsSubmittingTemplate(false);
+ }
+ };
+
+ const handleArchiveTemplate = async (template: ContractTemplate): Promise => {
+ if (!archiveContractAction) return false;
+ const result = await archiveContractAction({ id: template.id, isActive: false });
+ if (result.success) {
+ setTemplates((prev) =>
+ prev.map((item) => (item.id === template.id ? { ...item, isActive: false } : item)),
+ );
+ router.refresh();
+ return true;
+ }
+ return false;
+ };
+
+ const handleRestoreTemplate = async (template: ContractTemplate): Promise => {
+ if (!archiveContractAction) return false;
+ const result = await archiveContractAction({ id: template.id, isActive: true });
+ if (result.success) {
+ setTemplates((prev) =>
+ prev.map((item) => (item.id === template.id ? { ...item, isActive: true } : item)),
+ );
+ router.refresh();
+ return true;
+ }
+ return false;
+ };
+
+ const handleSaveSigningSettings = async (data: StudioSigningFormData): Promise => {
+ if (!updateSigningAction) return false;
+ const result = await updateSigningAction(data);
+ if (result.success) {
+ setSigningSettings(result.data);
+ router.refresh();
+ return true;
+ }
+ return false;
+ };
+
+ const handleTemplatesTabKeyDown = (e: React.KeyboardEvent) => {
+ handleTabKeyDown(e, 'templates');
+ };
+ const handleSigningTabKeyDown = (e: React.KeyboardEvent) => {
+ handleTabKeyDown(e, 'signing');
+ };
+
+ return {
+ handleSelectTemplatesTab,
+ handleSelectSigningTab,
+ handleTabKeyDown,
+ handleTemplatesTabKeyDown,
+ handleSigningTabKeyDown,
+ handleNewTemplate,
+ handleEditTemplate,
+ handleDuplicateTemplate,
+ handleCloseModal,
+ handleSaveTemplate,
+ handleArchiveTemplate,
+ handleRestoreTemplate,
+ handleSaveSigningSettings,
+ };
+}
diff --git a/src/components/settings/contracts-settings-view.tsx b/src/components/settings/contracts-settings-view.tsx
new file mode 100644
index 0000000..219ecb0
--- /dev/null
+++ b/src/components/settings/contracts-settings-view.tsx
@@ -0,0 +1,194 @@
+'use client';
+
+import * as React from 'react';
+import { useTranslations } from 'next-intl';
+import { Link, useRouter } from '@/i18n/navigation';
+import { ChevronRight, FileText, PenTool } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ContractTemplatesList } from './contract-templates-list';
+import { ContractTemplateModal } from './contract-template-modal';
+import { StudioSigningSettingsCard } from './studio-signing-settings-card';
+import {
+ createContractTemplateAction,
+ updateContractTemplateAction,
+ archiveContractTemplateAction,
+ updateStudioSigningSettingsAction,
+} from '@/server/contracts/actions';
+import type { ContractTemplate } from '@/server/db/schema/contracts';
+import type { StudioSettings } from '@/server/db/schema/studio-settings';
+import { createContractsSettingsViewController } from './contracts-helpers';
+
+export type ContractsTab = 'templates' | 'signing';
+
+export interface ContractsSettingsViewProps {
+ initialTemplates: ContractTemplate[];
+ initialSigningSettings: StudioSettings;
+ defaultTab?: ContractsTab;
+}
+
+export function ContractsSettingsView({
+ initialTemplates,
+ initialSigningSettings,
+ defaultTab = 'templates',
+}: ContractsSettingsViewProps) {
+ const t = useTranslations('settings.contracts');
+ const router = useRouter();
+
+ const [activeTab, setActiveTab] = React.useState(defaultTab);
+ const [templates, setTemplates] = React.useState(initialTemplates);
+ const [signingSettings, setSigningSettings] =
+ React.useState(initialSigningSettings);
+
+ // Modal State
+ const [isModalOpen, setIsModalOpen] = React.useState(false);
+ const [editingTemplate, setEditingTemplate] = React.useState(null);
+ const [isDuplicate, setIsDuplicate] = React.useState(false);
+ const [isSubmittingTemplate, setIsSubmittingTemplate] = React.useState(false);
+ const modalTriggerRef = React.useRef(null);
+
+ const controller = createContractsSettingsViewController({
+ setActiveTab,
+ setEditingTemplate,
+ setIsDuplicate,
+ setIsModalOpen,
+ setIsSubmittingTemplate,
+ setTemplates,
+ setSigningSettings,
+ router,
+ createContractAction: createContractTemplateAction,
+ updateContractAction: updateContractTemplateAction,
+ archiveContractAction: archiveContractTemplateAction,
+ updateSigningAction: updateStudioSigningSettingsAction,
+ });
+
+ const tabs = [
+ {
+ id: 'templates' as const,
+ testId: 'tab-templates',
+ label: t('tabs.templates'),
+ icon: FileText,
+ onSelect: controller.handleSelectTemplatesTab,
+ onKeyDown: controller.handleTemplatesTabKeyDown,
+ },
+ {
+ id: 'signing' as const,
+ testId: 'tab-signing',
+ label: t('tabs.signing'),
+ icon: PenTool,
+ onSelect: controller.handleSelectSigningTab,
+ onKeyDown: controller.handleSigningTabKeyDown,
+ },
+ ];
+
+ return (
+
+ {/* Breadcrumbs */}
+
+
+ {/* Page Header */}
+
+
+ {t('title')}
+
+
{t('description')}
+
+
+ {/* Tabs */}
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ const isSelected = activeTab === tab.id;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Tab Panels */}
+ {activeTab === 'templates' && (
+
+
+
+ )}
+
+ {activeTab === 'signing' && (
+
+
+
+ )}
+
+ {/* Template Create / Edit Modal */}
+
+
+ );
+}
diff --git a/src/components/settings/settings-nav.tsx b/src/components/settings/settings-nav.tsx
index 89ef181..c72f765 100644
--- a/src/components/settings/settings-nav.tsx
+++ b/src/components/settings/settings-nav.tsx
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useTranslations } from 'next-intl';
-import { Shield, Cloud, ClipboardList, CalendarClock, Rss, Package } from 'lucide-react';
+import { Shield, Cloud, ClipboardList, CalendarClock, Rss, Package, FileText } from 'lucide-react';
import { Link, usePathname } from '@/i18n/navigation';
import { cn } from '@/lib/utils';
@@ -17,6 +17,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
const tAvail = useTranslations('settings.availability');
const tFeed = useTranslations('settings.calendarFeed');
const tPkg = useTranslations('settings.packages');
+ const tContract = useTranslations('settings.contracts');
const pathname = usePathname();
const links = [
@@ -38,6 +39,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
shortLabel: tPkg('navLabel'),
icon: Package,
},
+ {
+ href: '/settings/contracts',
+ label: tContract('title'),
+ shortLabel: tContract('navLabel'),
+ icon: FileText,
+ },
{
href: '/settings/calendar/feed',
label: tFeed('title'),
@@ -60,6 +67,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
return (