Compare commits
7 Commits
feature/do
...
feature/be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f7195edd | ||
|
|
b14be8583d | ||
|
|
67ad7c236b | ||
|
|
ea752088f6 | ||
|
|
edadf39445 | ||
| 1c3cec2c06 | |||
|
|
746daaef6d |
37
.gitea/workflows/build-pitch-deck.yml
Normal file
37
.gitea/workflows/build-pitch-deck.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Build + push pitch-deck Docker image to registry.meghsakha.com
|
||||
# on every push to main that touches pitch-deck/ files.
|
||||
|
||||
name: Build pitch-deck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pitch-deck/**'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd pitch-deck
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
|
||||
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
|
||||
.
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
|
||||
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
|
||||
echo "Pushed registry.meghsakha.com/breakpilot/pitch-deck:latest + :${SHORT_SHA}"
|
||||
284
control-pipeline/scripts/ingest_bag_urteile.py
Normal file
284
control-pipeline/scripts/ingest_bag_urteile.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Ingest BAG (Bundesarbeitsgericht) court decisions into RAG.
|
||||
|
||||
Downloads PDFs from bundesarbeitsgericht.de and uploads them to the
|
||||
bp_compliance_datenschutz Qdrant collection via the RAG-Service API.
|
||||
|
||||
These decisions are curated for IT/KI-Mitbestimmung relevance (§87 BetrVG).
|
||||
|
||||
Usage:
|
||||
python scripts/ingest_bag_urteile.py [--rag-url https://macmini:8097] [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated BAG decisions for IT/AI works council co-determination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BAG_DECISIONS = [
|
||||
# --- M365 / Copilot / Standardsoftware ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/",
|
||||
"case_number": "1 ABR 20/21",
|
||||
"date": "2022-03-08",
|
||||
"subject": "Microsoft Office 365 — Mitbestimmung",
|
||||
"keywords": ["Microsoft 365", "Standardsoftware", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/",
|
||||
"case_number": "1 ABN 36/18",
|
||||
"date": "2018-10-23",
|
||||
"subject": "Excel / Standardsoftware — keine Geringfuegigkeitsschwelle",
|
||||
"keywords": ["Excel", "Standardsoftware", "Geringfuegigkeit", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-45-11/",
|
||||
"case_number": "1 ABR 45/11",
|
||||
"date": "2012-09-25",
|
||||
"subject": "SAP ERP im Personalwesen",
|
||||
"keywords": ["SAP", "ERP", "Personalwesen", "Verhaltenskontrolle", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-31-19/",
|
||||
"case_number": "1 ABR 31/19",
|
||||
"date": "2021-01-27",
|
||||
"subject": "E-Mail-Kommunikationssoftware — Mitbestimmung",
|
||||
"keywords": ["E-Mail", "Kommunikation", "Software", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-13-17/",
|
||||
"case_number": "1 ABR 13/17",
|
||||
"date": "2019-07-09",
|
||||
"subject": "IT-System fuer Mitarbeiterbefragung",
|
||||
"keywords": ["Mitarbeiterbefragung", "Feedback", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-23/",
|
||||
"case_number": "1 ABR 16/23",
|
||||
"date": "2024-07-16",
|
||||
"subject": "Headset-System — Geraetenutzungsdaten",
|
||||
"keywords": ["Headset", "Geraetenutzung", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Ueberwachung, Social, Drittplattformen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-7-15/",
|
||||
"case_number": "1 ABR 7/15",
|
||||
"date": "2016-12-13",
|
||||
"subject": "Facebook-Seite — indirekte Leistungsueberwachung",
|
||||
"keywords": ["Facebook", "Social Media", "Besucherbeitraege", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-12/",
|
||||
"case_number": "1 ABR 43/12",
|
||||
"date": "2013-12-10",
|
||||
"subject": "Google Maps — indirekte Ueberwachung / Definition Ueberwachung",
|
||||
"keywords": ["Google Maps", "Routenplaner", "indirekte Ueberwachung", "Definition", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-68-13/",
|
||||
"case_number": "1 ABR 68/13",
|
||||
"date": "2015-07-21",
|
||||
"subject": "Ueberwachung durch technische Einrichtung eines Dritten (SaaS/Cloud)",
|
||||
"keywords": ["Drittsystem", "SaaS", "Cloud", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Video, Belastung, Leistungskennzahlen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-78-11/",
|
||||
"case_number": "1 ABR 78/11",
|
||||
"date": "2012-12-11",
|
||||
"subject": "Videoueberwachung — Grundsatzentscheidung",
|
||||
"keywords": ["Videoueberwachung", "Kamera", "Arbeitsplatz", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-15/",
|
||||
"case_number": "1 ABR 46/15",
|
||||
"date": "2017-04-25",
|
||||
"subject": "Belastungsstatistik — dauerhafte Kennzahlenueberwachung",
|
||||
"keywords": ["Belastungsstatistik", "Kennzahlen", "Analytics", "Persoenlichkeitsrecht", "§87 BetrVG"],
|
||||
},
|
||||
# --- Negative / abgrenzende Faelle ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-32-16/",
|
||||
"case_number": "1 ABR 32/16",
|
||||
"date": "2017-12-19",
|
||||
"subject": "Anti-Terror-Listen — keine Mitbestimmung",
|
||||
"keywords": ["Anti-Terror", "Sanktionsliste", "keine Mitbestimmung", "Abgrenzung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-22-21/",
|
||||
"case_number": "1 ABR 22/21",
|
||||
"date": "2022-09-13",
|
||||
"subject": "Elektronische Arbeitszeiterfassung — Initiativrecht",
|
||||
"keywords": ["Arbeitszeiterfassung", "Initiativrecht", "digitale Systeme", "§87 BetrVG"],
|
||||
},
|
||||
# --- Historische Grundsatzentscheidungen ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-43-81/",
|
||||
"case_number": "1 ABR 43/81",
|
||||
"date": "1983-12-06",
|
||||
"subject": "Grundsatz technische Ueberwachung — Eignung genuegt",
|
||||
"keywords": ["Grundsatz", "Eignung", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-23-82/",
|
||||
"case_number": "1 ABR 23/82",
|
||||
"date": "1984-09-14",
|
||||
"subject": "Erste Grundlinie IT-Systeme",
|
||||
"keywords": ["IT-System", "Grundlinie", "technische Einrichtung", "§87 BetrVG"],
|
||||
},
|
||||
# --- E-Mail / Internet ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-46-10/",
|
||||
"case_number": "1 ABR 46/10",
|
||||
"date": "2012-02-07",
|
||||
"subject": "Internet- und E-Mail-Nutzung — Kommunikationsdaten",
|
||||
"keywords": ["Internet", "E-Mail", "Kommunikationsdaten", "Auswertung", "§87 BetrVG"],
|
||||
},
|
||||
# --- HR / Bewertungssysteme ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-40-07/",
|
||||
"case_number": "1 ABR 40/07",
|
||||
"date": "2008-07-22",
|
||||
"subject": "Beurteilungssysteme — §94/§95 BetrVG",
|
||||
"keywords": ["Beurteilung", "Bewertungssystem", "HR", "§94 BetrVG", "§95 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-16-07/",
|
||||
"case_number": "1 ABR 16/07",
|
||||
"date": "2008-03-18",
|
||||
"subject": "Personalfrageboegen — Bewertung",
|
||||
"keywords": ["Personalfragebogen", "Bewertung", "HR-Tools", "§94 BetrVG"],
|
||||
},
|
||||
# --- Video / physische Ueberwachung ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-21-03/",
|
||||
"case_number": "1 ABR 21/03",
|
||||
"date": "2004-06-29",
|
||||
"subject": "Videoueberwachung Arbeitsplatz",
|
||||
"keywords": ["Video", "Kamera", "Arbeitsplatz", "Ueberwachung", "§87 BetrVG"],
|
||||
},
|
||||
# --- Zustaendigkeit ---
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-2-05/",
|
||||
"case_number": "1 ABR 2/05",
|
||||
"date": "2006-05-03",
|
||||
"subject": "Zustaendigkeit Betriebsrat bei konzernweiten Tools",
|
||||
"keywords": ["Zustaendigkeit", "Konzern", "Gesamtbetriebsrat", "§87 BetrVG"],
|
||||
},
|
||||
{
|
||||
"url": "https://www.bundesarbeitsgericht.de/entscheidung/1-abr-58-04/",
|
||||
"case_number": "1 ABR 58/04",
|
||||
"date": "2006-03-28",
|
||||
"subject": "Mitbestimmung bei Einfuehrung technischer Systeme",
|
||||
"keywords": ["Systemeinführung", "technische Systeme", "Mitbestimmung", "§87 BetrVG"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def normalize_case_number(case_number: str) -> str:
|
||||
"""Normalize case number for use as regulation_id."""
|
||||
return re.sub(r"[^a-z0-9]", "_", case_number.lower()).strip("_")
|
||||
|
||||
|
||||
def download_decision(url: str, client: httpx.Client) -> bytes:
|
||||
"""Download a BAG decision page as HTML."""
|
||||
resp = client.get(url, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_to_rag(
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
metadata: dict,
|
||||
rag_url: str,
|
||||
client: httpx.Client,
|
||||
) -> dict:
|
||||
"""Upload a document to the RAG service."""
|
||||
files = {"file": (filename, file_bytes, "text/html")}
|
||||
data = {
|
||||
"collection": "bp_compliance_datenschutz",
|
||||
"data_type": "compliance_datenschutz",
|
||||
"bundesland": "bund",
|
||||
"use_case": "court_decision",
|
||||
"year": metadata.get("date", "2024")[:4],
|
||||
"chunk_strategy": "legal",
|
||||
"chunk_size": "512",
|
||||
"chunk_overlap": "50",
|
||||
"metadata_json": json.dumps(metadata),
|
||||
}
|
||||
resp = client.post(f"{rag_url}/api/v1/documents/upload", files=files, data=data)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Ingest BAG court decisions into RAG")
|
||||
parser.add_argument("--rag-url", default="https://macmini:8097", help="RAG service URL")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Download only, don't upload")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = httpx.Client(timeout=60, verify=False)
|
||||
stats = {"downloaded": 0, "uploaded": 0, "errors": 0}
|
||||
|
||||
for decision in BAG_DECISIONS:
|
||||
case_id = normalize_case_number(decision["case_number"])
|
||||
print(f"\n--- {decision['case_number']}: {decision['subject']} ---")
|
||||
|
||||
# Download
|
||||
try:
|
||||
html_bytes = download_decision(decision["url"], client)
|
||||
stats["downloaded"] += 1
|
||||
print(f" Downloaded: {len(html_bytes)} bytes")
|
||||
except Exception as e:
|
||||
print(f" ERROR downloading: {e}")
|
||||
stats["errors"] += 1
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
continue
|
||||
|
||||
# Upload
|
||||
metadata = {
|
||||
"regulation_id": f"bag_{case_id}",
|
||||
"regulation_name_de": f"BAG {decision['case_number']} — {decision['subject']}",
|
||||
"category": "arbeitsrecht",
|
||||
"source": "bundesarbeitsgericht.de",
|
||||
"doc_type": "court_decision",
|
||||
"license": "public_domain_§5_UrhG",
|
||||
"court": "BAG",
|
||||
"case_number": decision["case_number"],
|
||||
"date": decision["date"],
|
||||
"subject_matter": decision["subject"],
|
||||
"keywords": decision["keywords"],
|
||||
}
|
||||
|
||||
try:
|
||||
result = upload_to_rag(
|
||||
html_bytes,
|
||||
f"bag_{case_id}.html",
|
||||
metadata,
|
||||
args.rag_url,
|
||||
client,
|
||||
)
|
||||
stats["uploaded"] += 1
|
||||
print(f" Uploaded: {result.get('chunks_count', '?')} chunks, doc_id={result.get('document_id', '?')}")
|
||||
except Exception as e:
|
||||
print(f" ERROR uploading: {e}")
|
||||
stats["errors"] += 1
|
||||
|
||||
time.sleep(1) # Rate limiting
|
||||
|
||||
print(f"\n=== Done: {stats['downloaded']} downloaded, {stats['uploaded']} uploaded, {stats['errors']} errors ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
214
document-templates/generators/betriebsvereinbarung_template.py
Normal file
214
document-templates/generators/betriebsvereinbarung_template.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Betriebsvereinbarung template generator — creates BV draft from UCCA assessment.
|
||||
|
||||
Generates a modular works council agreement (Betriebsvereinbarung) based on:
|
||||
- UCCA Assessment result (triggered rules, risk score, obligations)
|
||||
- Company profile (name, location, works council)
|
||||
- System details (name, type, modules)
|
||||
|
||||
Sections A-M follow the template in migration 006.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# -- Default verbotene Nutzungen nach BAG-Rechtsprechung --------------------
|
||||
|
||||
DEFAULT_VERBOTENE_NUTZUNGEN = [
|
||||
"Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter",
|
||||
"Erstellung individueller Persoenlichkeitsprofile oder Verhaltensanalysen",
|
||||
"Nutzung von Nutzungshistorien zu disziplinarischen Zwecken",
|
||||
"Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung (Art. 22 DSGVO)",
|
||||
"Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung",
|
||||
"Korrelation von Systemnutzungsdaten mit Leistungsbeurteilungen",
|
||||
]
|
||||
|
||||
AI_VERBOTENE_NUTZUNGEN = [
|
||||
"Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung am Arbeitsplatz",
|
||||
"KI-gestuetztes Social Scoring von Beschaeftigten",
|
||||
"Nutzung von KI-generierten Bewertungen als alleinige Grundlage fuer Personalentscheidungen",
|
||||
]
|
||||
|
||||
# -- Standard-TOM Massnahmen ------------------------------------------------
|
||||
|
||||
DEFAULT_TOM = [
|
||||
"Rollen- und Rechtekonzept mit Least-Privilege-Prinzip",
|
||||
"Verschluesselung der Daten bei Uebertragung (TLS 1.2+) und Speicherung (AES-256)",
|
||||
"Protokollierung aller administrativen Zugriffe",
|
||||
"Pseudonymisierung personenbezogener Daten, wo technisch moeglich",
|
||||
"Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen",
|
||||
"Getrennte Umgebungen fuer Test und Produktion",
|
||||
"Regelmaessige Sicherheitsupdates und Patch-Management",
|
||||
"Zugangsschutz durch Multi-Faktor-Authentifizierung fuer Administratoren",
|
||||
]
|
||||
|
||||
# -- Standard erlaubte Reports ----------------------------------------------
|
||||
|
||||
DEFAULT_ERLAUBTE_REPORTS = [
|
||||
"Systemgesundheit und Verfuegbarkeit (ohne Personenbezug)",
|
||||
"Lizenznutzung auf aggregierter Ebene (Abteilung/Standort, nicht Person)",
|
||||
"Sicherheitsereignisse und Anomalien",
|
||||
"Speicherplatznutzung (ohne Personenbezug)",
|
||||
"Fehlerstatistiken (technisch, nicht personenbezogen)",
|
||||
]
|
||||
|
||||
# -- Standard Datenarten bei IT/KI-Systemen ---------------------------------
|
||||
|
||||
DATENARTEN_MAP = {
|
||||
"email": "E-Mail-Metadaten (Absender, Empfaenger, Zeitstempel — NICHT Inhalte)",
|
||||
"chat": "Chat-/Messaging-Metadaten (Teilnehmer, Zeitstempel)",
|
||||
"document": "Dokumentenmetadaten (Ersteller, Aenderungsdatum, Dateiname)",
|
||||
"login": "Anmeldedaten (Benutzername, Zeitstempel, IP-Adresse)",
|
||||
"usage": "Nutzungsdaten (aufgerufene Funktionen, Nutzungsdauer — aggregiert)",
|
||||
"prompt": "KI-Eingaben und -Ausgaben (Prompts, Antworten)",
|
||||
"calendar": "Kalendereintraege (Betreff, Teilnehmer, Zeiten)",
|
||||
"hr": "Personalstammdaten (Name, Abteilung, Position, Eintrittsdatum)",
|
||||
"performance": "Leistungsdaten (Kennzahlen, Bewertungen, Zielvereinbarungen)",
|
||||
"video": "Videoaufnahmen (Arbeitsplatz, Zugangsbereiche)",
|
||||
"location": "Standortdaten (GPS, WLAN-basierte Ortung, Gebaeudezutritt)",
|
||||
}
|
||||
|
||||
|
||||
def generate_betriebsvereinbarung_draft(ctx: dict) -> dict:
|
||||
"""Generate a Betriebsvereinbarung draft from company + assessment context.
|
||||
|
||||
Args:
|
||||
ctx: Dict with keys:
|
||||
Required:
|
||||
- company_name: str
|
||||
- system_name: str
|
||||
- system_description: str
|
||||
Optional:
|
||||
- company_address: str
|
||||
- employer_representative: str
|
||||
- works_council_chair: str
|
||||
- system_vendor: str
|
||||
- locations: list[str]
|
||||
- departments: list[str]
|
||||
- modules: list[str]
|
||||
- purposes: list[str]
|
||||
- data_types: list[str] — keys from DATENARTEN_MAP
|
||||
- is_ai_system: bool
|
||||
- has_employee_monitoring: bool
|
||||
- has_hr_features: bool
|
||||
- has_video: bool
|
||||
- dpo_name: str
|
||||
- dpo_contact: str
|
||||
- audit_interval: str — e.g. "12 Monate"
|
||||
- duration: str — e.g. "unbefristet"
|
||||
- notice_period: str — e.g. "3 Monate"
|
||||
- retention_audit_logs: str — e.g. "90 Tage"
|
||||
- retention_usage_data: str — e.g. "30 Tage"
|
||||
- retention_prompts: str — e.g. "deaktiviert"
|
||||
- additional_forbidden: list[str]
|
||||
- additional_tom: list[str]
|
||||
- additional_reports: list[str]
|
||||
- betrvg_conflict_score: int — 0-100
|
||||
|
||||
Returns:
|
||||
Dict with placeholder values ready for template substitution.
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# Basic info
|
||||
result["UNTERNEHMEN_NAME"] = ctx.get("company_name", "{{UNTERNEHMEN_NAME}}")
|
||||
result["UNTERNEHMEN_SITZ"] = ctx.get("company_address", "{{UNTERNEHMEN_SITZ}}")
|
||||
result["ARBEITGEBER_VERTRETER"] = ctx.get("employer_representative", "{{ARBEITGEBER_VERTRETER}}")
|
||||
result["BETRIEBSRAT_VORSITZ"] = ctx.get("works_council_chair", "{{BETRIEBSRAT_VORSITZ}}")
|
||||
result["SYSTEM_NAME"] = ctx.get("system_name", "{{SYSTEM_NAME}}")
|
||||
result["SYSTEM_BESCHREIBUNG"] = ctx.get("system_description", "{{SYSTEM_BESCHREIBUNG}}")
|
||||
result["SYSTEM_HERSTELLER"] = ctx.get("system_vendor", "")
|
||||
result["DSB_NAME"] = ctx.get("dpo_name", "{{DSB_NAME}}")
|
||||
result["DSB_KONTAKT"] = ctx.get("dpo_contact", "{{DSB_KONTAKT}}")
|
||||
|
||||
# B. Geltungsbereich
|
||||
locations = ctx.get("locations", [])
|
||||
result["GELTUNGSBEREICH_STANDORTE"] = _bullet_list(locations) if locations else "Alle Standorte der {{UNTERNEHMEN_NAME}}"
|
||||
|
||||
departments = ctx.get("departments", [])
|
||||
result["GELTUNGSBEREICH_BEREICHE"] = _bullet_list(departments) if departments else "Alle Beschaeftigten"
|
||||
|
||||
modules = ctx.get("modules", [])
|
||||
result["GELTUNGSBEREICH_MODULE"] = _bullet_list(modules) if modules else "Alle Module und Dienste von {{SYSTEM_NAME}}"
|
||||
|
||||
# C. Zweck
|
||||
purposes = ctx.get("purposes", [])
|
||||
result["ZWECK_BESCHREIBUNG"] = _bullet_list(purposes) if purposes else "{{ZWECK_BESCHREIBUNG}}"
|
||||
|
||||
# C.2 Verbotene Nutzungen
|
||||
forbidden = list(DEFAULT_VERBOTENE_NUTZUNGEN)
|
||||
if ctx.get("is_ai_system"):
|
||||
forbidden.extend(AI_VERBOTENE_NUTZUNGEN)
|
||||
forbidden.extend(ctx.get("additional_forbidden", []))
|
||||
result["VERBOTENE_NUTZUNGEN"] = _bullet_list(forbidden)
|
||||
|
||||
# D. Datenarten
|
||||
data_type_keys = ctx.get("data_types", [])
|
||||
datenarten = []
|
||||
for key in data_type_keys:
|
||||
if key in DATENARTEN_MAP:
|
||||
datenarten.append(DATENARTEN_MAP[key])
|
||||
else:
|
||||
datenarten.append(key)
|
||||
result["DATENARTEN_LISTE"] = _bullet_list(datenarten) if datenarten else "{{DATENARTEN_LISTE}}"
|
||||
|
||||
# E. Rollen
|
||||
result["ROLLEN_ADMIN"] = ctx.get("roles_admin", "IT-Administration: Systemkonfiguration, Benutzerverwaltung, Sicherheitsupdates")
|
||||
result["ROLLEN_FUEHRUNGSKRAFT"] = ctx.get("roles_manager", "Fuehrungskraefte: Nur aggregierte, nicht-personenbezogene Reports")
|
||||
result["ROLLEN_REPORTING"] = ctx.get("roles_reporting", "Controlling/Reporting: Nur freigegebene Standardreports (siehe Abschnitt G)")
|
||||
|
||||
# F. Transparenz
|
||||
result["TRANSPARENZ_INFO"] = ctx.get("transparency_info",
|
||||
"Die Information erfolgt schriftlich und in einer Informationsveranstaltung vor Einfuehrung des Systems.")
|
||||
|
||||
# G. Reports
|
||||
reports = list(DEFAULT_ERLAUBTE_REPORTS)
|
||||
reports.extend(ctx.get("additional_reports", []))
|
||||
result["ERLAUBTE_REPORTS"] = _bullet_list(reports)
|
||||
|
||||
# H. Speicherfristen
|
||||
result["SPEICHERFRIST_AUDIT_LOGS"] = ctx.get("retention_audit_logs", "90 Tage")
|
||||
result["SPEICHERFRIST_NUTZUNGSDATEN"] = ctx.get("retention_usage_data", "30 Tage")
|
||||
result["SPEICHERFRIST_CHAT_PROMPTS"] = ctx.get("retention_prompts", "deaktiviert")
|
||||
|
||||
# I. TOM
|
||||
tom = list(DEFAULT_TOM)
|
||||
tom.extend(ctx.get("additional_tom", []))
|
||||
# Intensivere Schutzmassnahmen bei hohem Konflikt-Score
|
||||
conflict_score = ctx.get("betrvg_conflict_score", 0)
|
||||
if conflict_score >= 50:
|
||||
tom.append("Automatische Anomalie-Erkennung bei ungewoehnlichen Admin-Zugriffen")
|
||||
tom.append("Quartalsweise Datenschutz-Audit durch externen Prueer")
|
||||
if conflict_score >= 75:
|
||||
tom.append("Betriebsrat erhaelt Leserechte auf Audit-Log-Dashboard")
|
||||
tom.append("Jede Sonderauswertung wird dem Betriebsrat innerhalb von 24h gemeldet")
|
||||
result["TOM_MASSNAHMEN"] = _bullet_list(tom)
|
||||
|
||||
# J. Change-Management
|
||||
result["CHANGE_MANAGEMENT_PROZESS"] = ctx.get("change_process",
|
||||
"Die Arbeitgeberin informiert den Betriebsrat schriftlich ueber geplante Aenderungen "
|
||||
"mindestens 14 Kalendertage vor Umsetzung. Bei sicherheitskritischen Updates kann die "
|
||||
"Frist auf 3 Werktage verkuerzt werden.")
|
||||
|
||||
# K. Audit
|
||||
result["AUDIT_INTERVALL"] = ctx.get("audit_interval", "12 Monate")
|
||||
|
||||
# L. Beschwerde
|
||||
result["BESCHWERDE_ANSPRECHPARTNER"] = ctx.get("complaint_contacts",
|
||||
"- Direkter Vorgesetzter\n- Betriebsrat ({{BETRIEBSRAT_VORSITZ}})\n"
|
||||
"- Datenschutzbeauftragter ({{DSB_NAME}}, {{DSB_KONTAKT}})")
|
||||
|
||||
# M. Schluss
|
||||
result["LAUFZEIT"] = ctx.get("duration", "unbefristet")
|
||||
result["KUENDIGUNGSFRIST"] = ctx.get("notice_period", "3 Monate")
|
||||
result["DATUM_UNTERZEICHNUNG"] = ctx.get("signing_date", "{{DATUM_UNTERZEICHNUNG}}")
|
||||
|
||||
# Conditional flags
|
||||
result["AI_SYSTEM"] = ctx.get("is_ai_system", False)
|
||||
result["VIDEO_UEBERWACHUNG"] = ctx.get("has_video", False)
|
||||
result["HR_SYSTEM"] = ctx.get("has_hr_features", False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _bullet_list(items: list) -> str:
|
||||
"""Format a list as markdown bullet points."""
|
||||
return "\n".join(f"- {item}" for item in items)
|
||||
158
document-templates/generators/test_betriebsvereinbarung.py
Normal file
158
document-templates/generators/test_betriebsvereinbarung.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Tests for Betriebsvereinbarung template generator."""
|
||||
|
||||
import pytest
|
||||
from betriebsvereinbarung_template import (
|
||||
generate_betriebsvereinbarung_draft,
|
||||
DEFAULT_VERBOTENE_NUTZUNGEN,
|
||||
AI_VERBOTENE_NUTZUNGEN,
|
||||
DEFAULT_TOM,
|
||||
DATENARTEN_MAP,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateBetriebsvereinbarung:
|
||||
"""Tests for generate_betriebsvereinbarung_draft()."""
|
||||
|
||||
def test_minimal_context(self):
|
||||
"""Minimal context should produce valid output with placeholders."""
|
||||
ctx = {
|
||||
"company_name": "Test GmbH",
|
||||
"system_name": "Microsoft 365",
|
||||
"system_description": "Office-Suite mit KI-Funktionen",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["UNTERNEHMEN_NAME"] == "Test GmbH"
|
||||
assert result["SYSTEM_NAME"] == "Microsoft 365"
|
||||
assert "{{BETRIEBSRAT_VORSITZ}}" in result["BETRIEBSRAT_VORSITZ"]
|
||||
|
||||
def test_full_context(self):
|
||||
"""Full context should fill all placeholders."""
|
||||
ctx = {
|
||||
"company_name": "Acme Corp",
|
||||
"company_address": "Hamburg",
|
||||
"employer_representative": "Dr. Schmidt",
|
||||
"works_council_chair": "Fr. Mueller",
|
||||
"system_name": "Copilot",
|
||||
"system_description": "KI-Assistent",
|
||||
"system_vendor": "Microsoft",
|
||||
"locations": ["Hamburg", "Berlin"],
|
||||
"departments": ["IT", "HR"],
|
||||
"modules": ["Teams", "Outlook", "Word"],
|
||||
"purposes": ["Texterstellung", "Zusammenfassung"],
|
||||
"data_types": ["email", "chat", "login"],
|
||||
"is_ai_system": True,
|
||||
"dpo_name": "Dr. Datenschutz",
|
||||
"dpo_contact": "dsb@acme.de",
|
||||
"audit_interval": "6 Monate",
|
||||
"duration": "2 Jahre",
|
||||
"notice_period": "6 Monate",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["ARBEITGEBER_VERTRETER"] == "Dr. Schmidt"
|
||||
assert result["BETRIEBSRAT_VORSITZ"] == "Fr. Mueller"
|
||||
assert "Hamburg" in result["GELTUNGSBEREICH_STANDORTE"]
|
||||
assert "Berlin" in result["GELTUNGSBEREICH_STANDORTE"]
|
||||
assert "Teams" in result["GELTUNGSBEREICH_MODULE"]
|
||||
assert result["AUDIT_INTERVALL"] == "6 Monate"
|
||||
assert result["LAUFZEIT"] == "2 Jahre"
|
||||
assert result["AI_SYSTEM"] is True
|
||||
|
||||
def test_verbotene_nutzungen_default(self):
|
||||
"""Default forbidden uses should always be included."""
|
||||
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in DEFAULT_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_verbotene_nutzungen_ai_system(self):
|
||||
"""AI-specific forbidden uses should be added for AI systems."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"is_ai_system": True,
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in AI_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_verbotene_nutzungen_no_ai(self):
|
||||
"""AI-specific forbidden uses should NOT be added for non-AI systems."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"is_ai_system": False,
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
for nutzung in AI_VERBOTENE_NUTZUNGEN:
|
||||
assert nutzung not in result["VERBOTENE_NUTZUNGEN"]
|
||||
|
||||
def test_datenarten_mapping(self):
|
||||
"""Data types should be resolved from DATENARTEN_MAP."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"data_types": ["email", "prompt", "hr"],
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert DATENARTEN_MAP["email"] in result["DATENARTEN_LISTE"]
|
||||
assert DATENARTEN_MAP["prompt"] in result["DATENARTEN_LISTE"]
|
||||
assert DATENARTEN_MAP["hr"] in result["DATENARTEN_LISTE"]
|
||||
|
||||
def test_tom_high_conflict_score(self):
|
||||
"""High conflict score should add extra TOM measures."""
|
||||
ctx_low = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"betrvg_conflict_score": 20,
|
||||
}
|
||||
ctx_high = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"betrvg_conflict_score": 80,
|
||||
}
|
||||
|
||||
result_low = generate_betriebsvereinbarung_draft(ctx_low)
|
||||
result_high = generate_betriebsvereinbarung_draft(ctx_high)
|
||||
|
||||
# High score should have more TOM items
|
||||
low_count = result_low["TOM_MASSNAHMEN"].count("- ")
|
||||
high_count = result_high["TOM_MASSNAHMEN"].count("- ")
|
||||
assert high_count > low_count, f"High conflict ({high_count} TOMs) should have more than low ({low_count})"
|
||||
|
||||
def test_speicherfristen_defaults(self):
|
||||
"""Default retention periods should be set."""
|
||||
ctx = {"company_name": "Test", "system_name": "Tool", "system_description": "x"}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "90 Tage"
|
||||
assert result["SPEICHERFRIST_NUTZUNGSDATEN"] == "30 Tage"
|
||||
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "deaktiviert"
|
||||
|
||||
def test_custom_retention(self):
|
||||
"""Custom retention periods should override defaults."""
|
||||
ctx = {
|
||||
"company_name": "Test",
|
||||
"system_name": "Tool",
|
||||
"system_description": "x",
|
||||
"retention_audit_logs": "180 Tage",
|
||||
"retention_prompts": "7 Tage",
|
||||
}
|
||||
result = generate_betriebsvereinbarung_draft(ctx)
|
||||
|
||||
assert result["SPEICHERFRIST_AUDIT_LOGS"] == "180 Tage"
|
||||
assert result["SPEICHERFRIST_CHAT_PROMPTS"] == "7 Tage"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,350 @@
|
||||
-- Migration 006: Betriebsvereinbarung Template V1
|
||||
-- Modulare Vorlage fuer Betriebsvereinbarungen zu KI/IT-Systemen
|
||||
-- Rechtsgrundlage: §87 Abs.1 Nr.6 BetrVG, DSGVO, BDSG
|
||||
|
||||
INSERT INTO compliance.compliance_legal_templates (
|
||||
tenant_id, document_type, title, description, language, jurisdiction,
|
||||
version, status, license_name, source_name, attribution_required,
|
||||
is_complete_document, placeholders, content
|
||||
) VALUES (
|
||||
'9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'::uuid,
|
||||
'betriebsvereinbarung',
|
||||
'Betriebsvereinbarung — Einfuehrung und Nutzung von KI-/IT-Systemen',
|
||||
'Modulare Vorlage fuer eine Betriebsvereinbarung gemaess §87 Abs.1 Nr.6 BetrVG zur Einfuehrung und Nutzung von IT-Systemen und KI-Anwendungen. Umfasst Datenschutz, Ueberwachungsschutz, Change-Management und Kontrollrechte des Betriebsrats. Basiert auf BAG-Rechtsprechung zu Microsoft 365, SAP ERP und Standardsoftware.',
|
||||
'de',
|
||||
'DE',
|
||||
'1.0',
|
||||
'published',
|
||||
'MIT',
|
||||
'BreakPilot Compliance',
|
||||
false,
|
||||
true,
|
||||
CAST('[
|
||||
"{{UNTERNEHMEN_NAME}}",
|
||||
"{{UNTERNEHMEN_SITZ}}",
|
||||
"{{ARBEITGEBER_VERTRETER}}",
|
||||
"{{BETRIEBSRAT_VORSITZ}}",
|
||||
"{{SYSTEM_NAME}}",
|
||||
"{{SYSTEM_BESCHREIBUNG}}",
|
||||
"{{SYSTEM_HERSTELLER}}",
|
||||
"{{GELTUNGSBEREICH_STANDORTE}}",
|
||||
"{{GELTUNGSBEREICH_BEREICHE}}",
|
||||
"{{GELTUNGSBEREICH_MODULE}}",
|
||||
"{{ZWECK_BESCHREIBUNG}}",
|
||||
"{{DATENARTEN_LISTE}}",
|
||||
"{{VERBOTENE_NUTZUNGEN}}",
|
||||
"{{ROLLEN_ADMIN}}",
|
||||
"{{ROLLEN_FUEHRUNGSKRAFT}}",
|
||||
"{{ROLLEN_REPORTING}}",
|
||||
"{{TRANSPARENZ_INFO}}",
|
||||
"{{ERLAUBTE_REPORTS}}",
|
||||
"{{SPEICHERFRIST_AUDIT_LOGS}}",
|
||||
"{{SPEICHERFRIST_NUTZUNGSDATEN}}",
|
||||
"{{SPEICHERFRIST_CHAT_PROMPTS}}",
|
||||
"{{TOM_MASSNAHMEN}}",
|
||||
"{{CHANGE_MANAGEMENT_PROZESS}}",
|
||||
"{{AUDIT_INTERVALL}}",
|
||||
"{{BESCHWERDE_ANSPRECHPARTNER}}",
|
||||
"{{LAUFZEIT}}",
|
||||
"{{KUENDIGUNGSFRIST}}",
|
||||
"{{DATUM_UNTERZEICHNUNG}}",
|
||||
"{{DSB_NAME}}",
|
||||
"{{DSB_KONTAKT}}"
|
||||
]' AS jsonb),
|
||||
$template$# Betriebsvereinbarung
|
||||
|
||||
**ueber die Einfuehrung und Nutzung von {{SYSTEM_NAME}}**
|
||||
|
||||
zwischen
|
||||
|
||||
**{{UNTERNEHMEN_NAME}}**, {{UNTERNEHMEN_SITZ}},
|
||||
vertreten durch {{ARBEITGEBER_VERTRETER}}
|
||||
(nachfolgend "Arbeitgeberin")
|
||||
|
||||
und dem
|
||||
|
||||
**Betriebsrat** der {{UNTERNEHMEN_NAME}},
|
||||
vertreten durch den/die Vorsitzende/n {{BETRIEBSRAT_VORSITZ}}
|
||||
(nachfolgend "Betriebsrat")
|
||||
|
||||
---
|
||||
|
||||
## A. Praeambel und Rechtsgrundlagen
|
||||
|
||||
Diese Betriebsvereinbarung regelt die Einfuehrung und Nutzung von **{{SYSTEM_NAME}}** ({{SYSTEM_BESCHREIBUNG}}) im Betrieb der {{UNTERNEHMEN_NAME}}.
|
||||
|
||||
**Rechtsgrundlagen:**
|
||||
- §87 Abs.1 Nr.6 BetrVG (Mitbestimmung bei technischen Ueberwachungseinrichtungen)
|
||||
- §90 BetrVG (Unterrichtung bei Planung technischer Anlagen)
|
||||
- Art. 5, 6, 32 DSGVO (Datenschutzgrundsaetze, Rechtsgrundlage, TOM)
|
||||
- §26 BDSG (Beschaeftigtendatenschutz)
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Verordnung (EU) 2024/1689 (KI-Verordnung / AI Act)
|
||||
{{/IF}}
|
||||
|
||||
Die Parteien sind sich einig, dass {{SYSTEM_NAME}} eine technische Einrichtung im Sinne des §87 Abs.1 Nr.6 BetrVG darstellt, die geeignet ist, das Verhalten oder die Leistung der Beschaeftigten zu ueberwachen. Die Einigung erfolgt in Kenntnis der Rechtsprechung des Bundesarbeitsgerichts (vgl. BAG 1 ABR 20/21 — Microsoft Office 365; BAG 1 ABN 36/18 — Standardsoftware).
|
||||
|
||||
---
|
||||
|
||||
## B. Geltungsbereich
|
||||
|
||||
### B.1 Raeumlicher Geltungsbereich
|
||||
Diese Betriebsvereinbarung gilt fuer folgende Standorte:
|
||||
{{GELTUNGSBEREICH_STANDORTE}}
|
||||
|
||||
### B.2 Persoenlicher Geltungsbereich
|
||||
Die Betriebsvereinbarung gilt fuer alle Beschaeftigten der folgenden Bereiche:
|
||||
{{GELTUNGSBEREICH_BEREICHE}}
|
||||
|
||||
### B.3 Sachlicher Geltungsbereich
|
||||
Die Betriebsvereinbarung umfasst folgende Module und Dienste des Systems:
|
||||
{{GELTUNGSBEREICH_MODULE}}
|
||||
|
||||
{{#IF SYSTEM_HERSTELLER}}
|
||||
**Systemhersteller/-anbieter:** {{SYSTEM_HERSTELLER}}
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## C. Zweckbestimmung
|
||||
|
||||
### C.1 Erlaubte Nutzungszwecke
|
||||
{{SYSTEM_NAME}} darf ausschliesslich zu folgenden Zwecken eingesetzt werden:
|
||||
{{ZWECK_BESCHREIBUNG}}
|
||||
|
||||
### C.2 Verbotene Nutzungen
|
||||
Folgende Nutzungen sind ausdruecklich untersagt:
|
||||
|
||||
{{VERBOTENE_NUTZUNGEN}}
|
||||
|
||||
Darueber hinaus ist generell untersagt:
|
||||
- Verdeckte Leistungs- oder Verhaltenskontrolle einzelner Beschaeftigter
|
||||
- Erstellung individueller Persoenlichkeitsprofile
|
||||
- Nutzung von Prompt-, Chat- oder Nutzungshistorien zu disziplinarischen Zwecken
|
||||
- Automatisierte Personalentscheidungen ohne menschliche Ueberpruefung
|
||||
- Personenbezogene Rankings oder Leistungsvergleiche ohne gesonderte Mitbestimmung
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Einsatz von KI-Funktionen zur biometrischen Echtzeit-Identifizierung
|
||||
- KI-gestuetztes Social Scoring von Beschaeftigten
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## D. Datenarten und Verarbeitungszwecke
|
||||
|
||||
### D.1 Verarbeitete Datenarten
|
||||
Im Rahmen der Nutzung von {{SYSTEM_NAME}} werden folgende Datenarten verarbeitet:
|
||||
{{DATENARTEN_LISTE}}
|
||||
|
||||
### D.2 Rechtsgrundlage
|
||||
Die Verarbeitung der Beschaeftigtendaten erfolgt auf Grundlage von:
|
||||
- §26 Abs.1 BDSG i.V.m. Art. 6 Abs.1 lit. b DSGVO (Durchfuehrung des Arbeitsverhaeltnisses)
|
||||
- §26 Abs.4 BDSG i.V.m. Art. 88 DSGVO (diese Betriebsvereinbarung als Kollektivvereinbarung)
|
||||
|
||||
### D.3 Keine Verarbeitung besonderer Kategorien
|
||||
Daten gemaess Art. 9 DSGVO (Gesundheitsdaten, Gewerkschaftszugehoerigkeit, biometrische Daten etc.) werden nicht verarbeitet, es sei denn, dies ist in einem gesonderten Anhang zu dieser Betriebsvereinbarung ausdruecklich geregelt.
|
||||
|
||||
---
|
||||
|
||||
## E. Rollen- und Zugriffskonzept
|
||||
|
||||
### E.1 Administratoren
|
||||
{{ROLLEN_ADMIN}}
|
||||
|
||||
### E.2 Fuehrungskraefte
|
||||
{{ROLLEN_FUEHRUNGSKRAFT}}
|
||||
|
||||
Fuehrungskraefte erhalten **keinen** Zugriff auf:
|
||||
- individuelle Nutzungsprotokolle
|
||||
- Prompt-/Chat-Historien einzelner Beschaeftigter
|
||||
- Produktivitaetskennzahlen auf Personenebene
|
||||
|
||||
### E.3 Reporting-Zugriff
|
||||
{{ROLLEN_REPORTING}}
|
||||
|
||||
### E.4 Vier-Augen-Prinzip
|
||||
Sonderauswertungen mit Personenbezug beduerfen:
|
||||
- der Zustimmung des Betriebsrats
|
||||
- der Beteiligung des Datenschutzbeauftragten ({{DSB_NAME}}, {{DSB_KONTAKT}})
|
||||
- einer dokumentierten Begruendung
|
||||
|
||||
---
|
||||
|
||||
## F. Transparenz gegenueber Beschaeftigten
|
||||
|
||||
Die Arbeitgeberin informiert alle Beschaeftigten vor Einfuehrung von {{SYSTEM_NAME}} ueber:
|
||||
{{TRANSPARENZ_INFO}}
|
||||
|
||||
Insbesondere:
|
||||
- Welche Daten verarbeitet werden
|
||||
- Welche KI-Funktionen aktiviert sind
|
||||
- Welche Protokollierung stattfindet
|
||||
- Wer Zugriff auf welche Daten hat
|
||||
- Wie lange Daten gespeichert werden
|
||||
- An wen sich Beschaeftigte bei Fragen oder Beschwerden wenden koennen
|
||||
|
||||
{{#IF AI_SYSTEM}}
|
||||
Bei KI-gestuetzten Funktionen wird zusaetzlich transparent gemacht:
|
||||
- Ob und wie KI-generierte Inhalte gekennzeichnet werden
|
||||
- Ob Eingaben fuer Modelltraining verwendet werden (Standard: Nein)
|
||||
- Welche Entscheidungsunterstuetzung die KI leistet
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## G. Auswertungen und Reports
|
||||
|
||||
### G.1 Erlaubte Reports
|
||||
Folgende Auswertungen sind ohne gesonderte Zustimmung zulaessig:
|
||||
{{ERLAUBTE_REPORTS}}
|
||||
|
||||
### G.2 Unzulaessige Reports
|
||||
Ohne ausdrueckliche, vorherige Zustimmung des Betriebsrats sind unzulaessig:
|
||||
- individuelle Produktivitaetsreports
|
||||
- Teamvergleiche mit Personenbezug
|
||||
- Verhaltensprofile oder Nutzungsmuster einzelner Beschaeftigter
|
||||
- Rankinglisten (auch anonymisierte, wenn Re-Identifikation moeglich)
|
||||
- Korrelation von Nutzungsdaten mit Leistungsbeurteilungen
|
||||
|
||||
### G.3 Neue Reporttypen
|
||||
Die Einfuehrung neuer Reporttypen bedarf der vorherigen Zustimmung des Betriebsrats.
|
||||
|
||||
---
|
||||
|
||||
## H. Speicher- und Loeschfristen
|
||||
|
||||
| Datenkategorie | Speicherfrist | Loeschverfahren |
|
||||
|----------------|---------------|-----------------|
|
||||
| Audit-/Admin-Logs | {{SPEICHERFRIST_AUDIT_LOGS}} | Automatische Loeschung |
|
||||
| Nutzungsdaten (aggregiert) | {{SPEICHERFRIST_NUTZUNGSDATEN}} | Automatische Loeschung |
|
||||
| Prompt-/Chat-Historien | {{SPEICHERFRIST_CHAT_PROMPTS}} | Automatische Loeschung oder deaktiviert |
|
||||
| Exportdateien | 30 Tage | Automatische Loeschung |
|
||||
|
||||
Die Speicherdauer der Audit-Logs orientiert sich am berechtigten Interesse der Arbeitgeberin an der Systemsicherheit und wird auf das erforderliche Minimum begrenzt.
|
||||
|
||||
{{#IF AI_SYSTEM}}
|
||||
**KI-spezifisch:**
|
||||
- Trainingsdaten aus Beschaeftigten-Interaktionen: **nicht zulaessig** ohne gesonderte Vereinbarung
|
||||
- Feedback-Daten zur Modellverbesserung: nur anonymisiert und aggregiert
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
## I. Technische und organisatorische Massnahmen (TOM)
|
||||
|
||||
Zum Schutz der Beschaeftigtendaten werden folgende Massnahmen umgesetzt:
|
||||
|
||||
{{TOM_MASSNAHMEN}}
|
||||
|
||||
Ergaenzend gelten mindestens:
|
||||
- Rollen- und Rechtekonzept mit Least-Privilege-Prinzip
|
||||
- Verschluesselung der Daten bei Uebertragung und Speicherung
|
||||
- Protokollierung aller administrativen Zugriffe
|
||||
- Pseudonymisierung, wo technisch moeglich
|
||||
- Deaktivierung nicht benoetigter Telemetrie- und Diagnosefunktionen
|
||||
- Getrennte Umgebungen fuer Test und Produktion
|
||||
|
||||
---
|
||||
|
||||
## J. Change-Management
|
||||
|
||||
### J.1 Aenderungspflicht
|
||||
Folgende Aenderungen an {{SYSTEM_NAME}} beduerfen der vorherigen Information und ggf. erneuten Mitbestimmung des Betriebsrats:
|
||||
|
||||
{{CHANGE_MANAGEMENT_PROZESS}}
|
||||
|
||||
Insbesondere:
|
||||
- Aktivierung neuer Module oder Funktionen
|
||||
- Anbindung neuer Datenquellen oder Konnektoren
|
||||
- Aenderung der Reporting-Funktionalitaet
|
||||
- Updates mit neuen KI-Modellen oder -Funktionen
|
||||
- Aenderung der Datenverarbeitungsstandorte
|
||||
- Erweiterung des Nutzerkreises
|
||||
|
||||
### J.2 Informationsfrist
|
||||
Die Arbeitgeberin informiert den Betriebsrat mindestens **14 Kalendertage** vor geplanten Aenderungen schriftlich. Bei sicherheitskritischen Updates kann die Frist auf 3 Werktage verkuerzt werden.
|
||||
|
||||
### J.3 Bewertungsverfahren
|
||||
Jede Aenderung wird anhand folgender Kriterien bewertet:
|
||||
- Aendert sich die Ueberwachungseignung?
|
||||
- Werden neue Datenarten verarbeitet?
|
||||
- Aendert sich der Personenbezug?
|
||||
|
||||
Bei positiver Beantwortung einer dieser Fragen ist eine erneute Mitbestimmung erforderlich.
|
||||
|
||||
---
|
||||
|
||||
## K. Kontroll- und Audit-Rechte des Betriebsrats
|
||||
|
||||
### K.1 Laufende Kontrolle
|
||||
Der Betriebsrat hat das Recht auf:
|
||||
- Einsicht in die Systemdokumentation
|
||||
- Einsicht in den Katalog aktiver Reports und Auswertungen
|
||||
- Information ueber alle Administrationszugriffe
|
||||
- Teilnahme an Schulungen zum System
|
||||
|
||||
### K.2 Regelmaessige Reviews
|
||||
Arbeitgeberin und Betriebsrat fuehren alle **{{AUDIT_INTERVALL}}** einen gemeinsamen Review durch. Gegenstand:
|
||||
- Aktuelle Nutzung und Funktionsumfang
|
||||
- Eingehaltene/verletzte Regelungen
|
||||
- Eingegangene Beschwerden
|
||||
- Geplante Aenderungen
|
||||
- Aktualitaet der TOM
|
||||
|
||||
### K.3 Anlassbezogene Pruefung
|
||||
Bei begruendetem Verdacht auf Verstoss gegen diese Betriebsvereinbarung kann der Betriebsrat jederzeit eine Sonderpruefung verlangen. Die Arbeitgeberin stellt innerhalb von 5 Werktagen die angeforderten Informationen bereit.
|
||||
|
||||
---
|
||||
|
||||
## L. Beschwerden und Eskalation
|
||||
|
||||
### L.1 Beschwerderecht
|
||||
Beschaeftigte koennen sich bei Bedenken hinsichtlich der Datenverarbeitung wenden an:
|
||||
{{BESCHWERDE_ANSPRECHPARTNER}}
|
||||
|
||||
### L.2 Eskalation
|
||||
Bei Meinungsverschiedenheiten ueber die Auslegung oder Anwendung dieser Betriebsvereinbarung gilt:
|
||||
1. Gespraech zwischen Arbeitgeberin und Betriebsrat (Frist: 2 Wochen)
|
||||
2. Hinzuziehung des Datenschutzbeauftragten
|
||||
3. Einigungsstelle gemaess §76 BetrVG
|
||||
|
||||
### L.3 Sofortmassnahmen
|
||||
Bei schwerwiegenden Verstoessen (insbesondere unzulaessige Ueberwachung, Datenmissbrauch) kann der Betriebsrat die sofortige Aussetzung der betroffenen Funktion verlangen. Die Arbeitgeberin setzt die Funktion bis zur Klaerung aus.
|
||||
|
||||
---
|
||||
|
||||
## M. Schlussbestimmungen
|
||||
|
||||
### M.1 Inkrafttreten und Laufzeit
|
||||
Diese Betriebsvereinbarung tritt am {{DATUM_UNTERZEICHNUNG}} in Kraft und gilt fuer die Dauer von {{LAUFZEIT}}.
|
||||
|
||||
### M.2 Kuendigung
|
||||
Die Betriebsvereinbarung kann von jeder Seite mit einer Frist von {{KUENDIGUNGSFRIST}} zum Monatsende schriftlich gekuendigt werden.
|
||||
|
||||
### M.3 Nachwirkung
|
||||
Die Betriebsvereinbarung wirkt nach Kuendigung bis zum Abschluss einer neuen Vereinbarung nach (§77 Abs.6 BetrVG).
|
||||
|
||||
### M.4 Salvatorische Klausel
|
||||
Sollten einzelne Bestimmungen unwirksam sein, bleibt die Wirksamkeit der uebrigen Bestimmungen unberuehrt. Die Parteien verpflichten sich, unwirksame Bestimmungen durch wirksame zu ersetzen, die dem wirtschaftlichen Zweck am naechsten kommen.
|
||||
|
||||
### M.5 Anlagen
|
||||
Folgende Anlagen sind Bestandteil dieser Betriebsvereinbarung:
|
||||
- Anlage 1: Detaillierte Systemdokumentation
|
||||
- Anlage 2: Rollen- und Rechtekonzept
|
||||
- Anlage 3: TOM-Dokumentation
|
||||
- Anlage 4: Reportkatalog
|
||||
{{#IF AI_SYSTEM}}
|
||||
- Anlage 5: KI-Transparenzbericht
|
||||
{{/IF}}
|
||||
|
||||
---
|
||||
|
||||
**{{UNTERNEHMEN_SITZ}}, den {{DATUM_UNTERZEICHNUNG}}**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _________________________ | _________________________ |
|
||||
| {{ARBEITGEBER_VERTRETER}} | {{BETRIEBSRAT_VORSITZ}} |
|
||||
| fuer die Arbeitgeberin | fuer den Betriebsrat |
|
||||
$template$
|
||||
) ON CONFLICT DO NOTHING;
|
||||
@@ -14,8 +14,12 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const [investor, sessions, snapshots, audit] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at
|
||||
FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
||||
i.created_at, i.updated_at, i.assigned_version_id,
|
||||
v.name AS version_name, v.status AS version_status
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
WHERE i.id = $1`,
|
||||
[id],
|
||||
),
|
||||
pool.query(
|
||||
@@ -60,36 +64,58 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, company } = body
|
||||
const { name, company, assigned_version_id } = body
|
||||
|
||||
if (name === undefined && company === undefined) {
|
||||
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, company FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate version exists and is committed (if assigning)
|
||||
if (assigned_version_id !== undefined && assigned_version_id !== null) {
|
||||
const ver = await pool.query(
|
||||
`SELECT id, status FROM pitch_versions WHERE id = $1`,
|
||||
[assigned_version_id],
|
||||
)
|
||||
if (ver.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
}
|
||||
if (ver.rows[0].status !== 'committed') {
|
||||
return NextResponse.json({ error: 'Can only assign committed versions' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Use null to clear version assignment, undefined to leave unchanged
|
||||
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET
|
||||
name = COALESCE($1, name),
|
||||
company = COALESCE($2, company),
|
||||
assigned_version_id = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, name, company, status`,
|
||||
[name ?? null, company ?? null, id],
|
||||
RETURNING id, email, name, company, status, assigned_version_id`,
|
||||
[name ?? null, company ?? null, id, versionValue],
|
||||
)
|
||||
|
||||
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
|
||||
? 'investor_version_assigned'
|
||||
: 'investor_edited'
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'investor_edited',
|
||||
action,
|
||||
{
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, company: rows[0].company },
|
||||
after: { name: rows[0].name, company: rows[0].company, assigned_version_id: rows[0].assigned_version_id },
|
||||
},
|
||||
request,
|
||||
id,
|
||||
|
||||
@@ -8,9 +8,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at,
|
||||
i.assigned_version_id, v.name AS version_name,
|
||||
(SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed,
|
||||
(SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
ORDER BY i.created_at DESC`,
|
||||
)
|
||||
|
||||
|
||||
31
pitch-deck/app/api/admin/versions/[id]/commit/route.ts
Normal file
31
pitch-deck/app/api/admin/versions/[id]/commit/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Already committed' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_versions SET status = 'committed', committed_at = NOW() WHERE id = $1 RETURNING *`,
|
||||
[id],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_committed', {
|
||||
version_id: id,
|
||||
name: rows[0].name,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version: rows[0] })
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { VERSION_TABLES, VersionTableName } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string; tableName: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id, tableName } = await ctx.params
|
||||
|
||||
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
|
||||
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT data, updated_at, updated_by FROM pitch_version_data
|
||||
WHERE version_id = $1 AND table_name = $2`,
|
||||
[id, tableName],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ data: [], updated_at: null })
|
||||
}
|
||||
|
||||
const data = typeof rows[0].data === 'string' ? JSON.parse(rows[0].data) : rows[0].data
|
||||
return NextResponse.json({ data, updated_at: rows[0].updated_at })
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id, tableName } = await ctx.params
|
||||
|
||||
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
|
||||
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify version is a draft
|
||||
const ver = await pool.query(`SELECT status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { data } = body
|
||||
if (!Array.isArray(data) && typeof data !== 'object') {
|
||||
return NextResponse.json({ error: 'data must be an array or object' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Wrap single-record tables in array for consistency
|
||||
const normalizedData = Array.isArray(data) ? data : [data]
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (version_id, table_name) DO UPDATE SET
|
||||
data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[id, tableName, JSON.stringify(normalizedData), adminId],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_data_edited', {
|
||||
version_id: id,
|
||||
table_name: tableName,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
import { loadVersionData, VERSION_TABLES } from '@/lib/version-helpers'
|
||||
import { diffTable } from '@/lib/version-diff'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string; otherId: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id, otherId } = await ctx.params
|
||||
|
||||
// Verify both versions exist
|
||||
const [vA, vB] = await Promise.all([
|
||||
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [id]),
|
||||
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [otherId]),
|
||||
])
|
||||
if (vA.rows.length === 0 || vB.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'One or both versions not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [dataA, dataB] = await Promise.all([
|
||||
loadVersionData(id),
|
||||
loadVersionData(otherId),
|
||||
])
|
||||
|
||||
const diffs = VERSION_TABLES.map(tableName =>
|
||||
diffTable(tableName, dataA[tableName] || [], dataB[tableName] || [])
|
||||
).filter(d => d.hasChanges)
|
||||
|
||||
return NextResponse.json({
|
||||
versionA: vA.rows[0],
|
||||
versionB: vB.rows[0],
|
||||
diffs,
|
||||
total_changes: diffs.reduce((sum, d) => sum + d.rows.filter(r => r.status !== 'unchanged').length, 0),
|
||||
})
|
||||
}
|
||||
38
pitch-deck/app/api/admin/versions/[id]/fork/route.ts
Normal file
38
pitch-deck/app/api/admin/versions/[id]/fork/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { copyVersionData } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function POST(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name = body.name || ''
|
||||
|
||||
const parent = await pool.query(`SELECT id, name, status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (parent.rows.length === 0) return NextResponse.json({ error: 'Parent version not found' }, { status: 404 })
|
||||
|
||||
const forkName = name.trim() || `${parent.rows[0].name} (fork)`
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_versions (name, parent_id, status, created_by)
|
||||
VALUES ($1, $2, 'draft', $3) RETURNING *`,
|
||||
[forkName, id, adminId],
|
||||
)
|
||||
const version = rows[0]
|
||||
|
||||
await copyVersionData(id, version.id, adminId)
|
||||
await logAdminAudit(adminId, 'version_forked', {
|
||||
version_id: version.id,
|
||||
parent_id: id,
|
||||
parent_name: parent.rows[0].name,
|
||||
name: forkName,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version })
|
||||
}
|
||||
85
pitch-deck/app/api/admin/versions/[id]/route.ts
Normal file
85
pitch-deck/app/api/admin/versions/[id]/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { loadVersionData } from '@/lib/version-helpers'
|
||||
|
||||
interface Ctx { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT v.*, a.name AS created_by_name, a.email AS created_by_email,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
|
||||
FROM pitch_versions v
|
||||
LEFT JOIN pitch_admins a ON a.id = v.created_by
|
||||
WHERE v.id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
const data = await loadVersionData(id)
|
||||
|
||||
return NextResponse.json({ version: rows[0], data })
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description } = body
|
||||
|
||||
const before = await pool.query(`SELECT name, description, status FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (before.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
if (before.rows[0].status === 'committed') {
|
||||
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_versions SET name = COALESCE($1, name), description = COALESCE($2, description)
|
||||
WHERE id = $3 RETURNING *`,
|
||||
[name ?? null, description ?? null, id],
|
||||
)
|
||||
|
||||
await logAdminAudit(adminId, 'version_edited', {
|
||||
version_id: id,
|
||||
before: { name: before.rows[0].name, description: before.rows[0].description },
|
||||
after: { name: rows[0].name, description: rows[0].description },
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version: rows[0] })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, ctx: Ctx) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
|
||||
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
// Prevent deleting committed versions that have children or assigned investors
|
||||
if (ver.rows[0].status === 'committed') {
|
||||
const children = await pool.query(`SELECT id FROM pitch_versions WHERE parent_id = $1 LIMIT 1`, [id])
|
||||
if (children.rows.length > 0) {
|
||||
return NextResponse.json({ error: 'Cannot delete: has child versions' }, { status: 400 })
|
||||
}
|
||||
const investors = await pool.query(`SELECT id FROM pitch_investors WHERE assigned_version_id = $1 LIMIT 1`, [id])
|
||||
if (investors.rows.length > 0) {
|
||||
return NextResponse.json({ error: 'Cannot delete: assigned to investors' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(`DELETE FROM pitch_versions WHERE id = $1`, [id])
|
||||
await logAdminAudit(adminId, 'version_deleted', { version_id: id, name: ver.rows[0].name }, request)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
56
pitch-deck/app/api/admin/versions/route.ts
Normal file
56
pitch-deck/app/api/admin/versions/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { snapshotBaseTables, copyVersionData } from '@/lib/version-helpers'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT v.*,
|
||||
a.name AS created_by_name, a.email AS created_by_email,
|
||||
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
|
||||
FROM pitch_versions v
|
||||
LEFT JOIN pitch_admins a ON a.id = v.created_by
|
||||
ORDER BY v.created_at DESC
|
||||
`)
|
||||
|
||||
return NextResponse.json({ versions: rows })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description, parent_id } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json({ error: 'name required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create the version row
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_versions (name, description, parent_id, status, created_by)
|
||||
VALUES ($1, $2, $3, 'draft', $4) RETURNING *`,
|
||||
[name.trim(), description || null, parent_id || null, adminId],
|
||||
)
|
||||
const version = rows[0]
|
||||
|
||||
// Copy data from parent or snapshot base tables
|
||||
if (parent_id) {
|
||||
await copyVersionData(parent_id, version.id, adminId)
|
||||
} else {
|
||||
await snapshotBaseTables(version.id, adminId)
|
||||
}
|
||||
|
||||
await logAdminAudit(adminId, 'version_created', {
|
||||
version_id: version.id,
|
||||
name: version.name,
|
||||
parent_id: parent_id || null,
|
||||
}, request)
|
||||
|
||||
return NextResponse.json({ version })
|
||||
}
|
||||
@@ -1,24 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await pool.connect()
|
||||
// Check if investor has an assigned version
|
||||
const session = await getSessionFromCookie()
|
||||
let versionId: string | null = null
|
||||
|
||||
if (session) {
|
||||
const inv = await pool.query(
|
||||
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub],
|
||||
)
|
||||
versionId = inv.rows[0]?.assigned_version_id || null
|
||||
}
|
||||
|
||||
// If version assigned, load from pitch_version_data
|
||||
if (versionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
const map: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
return NextResponse.json({
|
||||
company: (map.company || [])[0] || null,
|
||||
team: map.team || [],
|
||||
financials: map.financials || [],
|
||||
market: map.market || [],
|
||||
competitors: map.competitors || [],
|
||||
features: map.features || [],
|
||||
milestones: map.milestones || [],
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: read from base tables (backward compatible)
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const [
|
||||
companyRes,
|
||||
teamRes,
|
||||
financialsRes,
|
||||
marketRes,
|
||||
competitorsRes,
|
||||
featuresRes,
|
||||
milestonesRes,
|
||||
metricsRes,
|
||||
fundingRes,
|
||||
productsRes,
|
||||
companyRes, teamRes, financialsRes, marketRes, competitorsRes,
|
||||
featuresRes, milestonesRes, metricsRes, fundingRes, productsRes,
|
||||
] = await Promise.all([
|
||||
client.query('SELECT * FROM pitch_company LIMIT 1'),
|
||||
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
|
||||
@@ -49,9 +78,6 @@ export async function GET() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load pitch data' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to load pitch data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET: Load all scenarios with their assumptions
|
||||
function assembleScenarios(scenarioRows: Record<string, unknown>[], assumptionRows: Record<string, unknown>[]) {
|
||||
return scenarioRows.map(s => ({
|
||||
...s,
|
||||
assumptions: assumptionRows
|
||||
.filter((a: Record<string, unknown>) => a.scenario_id === (s as Record<string, unknown>).id)
|
||||
.map((a: Record<string, unknown>) => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
// GET: Load all scenarios with their assumptions (version-aware)
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if investor has an assigned version with FM data
|
||||
const session = await getSessionFromCookie()
|
||||
let versionId: string | null = null
|
||||
|
||||
if (session) {
|
||||
const inv = await pool.query(
|
||||
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub],
|
||||
)
|
||||
versionId = inv.rows[0]?.assigned_version_id || null
|
||||
}
|
||||
|
||||
if (versionId) {
|
||||
const [scenarioData, assumptionData] = await Promise.all([
|
||||
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_scenarios'`, [versionId]),
|
||||
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_assumptions'`, [versionId]),
|
||||
])
|
||||
|
||||
if (scenarioData.rows.length > 0) {
|
||||
const scenarios = typeof scenarioData.rows[0].data === 'string'
|
||||
? JSON.parse(scenarioData.rows[0].data) : scenarioData.rows[0].data
|
||||
const assumptions = assumptionData.rows.length > 0
|
||||
? (typeof assumptionData.rows[0].data === 'string'
|
||||
? JSON.parse(assumptionData.rows[0].data) : assumptionData.rows[0].data)
|
||||
: []
|
||||
return NextResponse.json(assembleScenarios(scenarios, assumptions))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: base tables
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const scenarios = await client.query(
|
||||
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
|
||||
)
|
||||
|
||||
const assumptions = await client.query(
|
||||
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
|
||||
)
|
||||
|
||||
const result = scenarios.rows.map(s => ({
|
||||
...s,
|
||||
assumptions: assumptions.rows
|
||||
.filter(a => a.scenario_id === s.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json(result)
|
||||
return NextResponse.json(assembleScenarios(scenarios.rows, assumptions.rows))
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
|
||||
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal file
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getAdminFromCookie } from '@/lib/admin-auth'
|
||||
|
||||
interface Ctx { params: Promise<{ versionId: string }> }
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
// Admin-only: verify admin session
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { versionId } = await ctx.params
|
||||
|
||||
// Load version data
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found or has no data' }, { status: 404 })
|
||||
}
|
||||
|
||||
const map: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
|
||||
// Return PitchData format
|
||||
return NextResponse.json({
|
||||
company: (map.company || [])[0] || null,
|
||||
team: map.team || [],
|
||||
financials: map.financials || [],
|
||||
market: map.market || [],
|
||||
competitors: map.competitors || [],
|
||||
features: map.features || [],
|
||||
milestones: map.milestones || [],
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
})
|
||||
}
|
||||
@@ -128,9 +128,95 @@ export default function EditScenarioPage() {
|
||||
<div className="space-y-3">
|
||||
{items.map(a => {
|
||||
const isEdited = edits[a.id] !== undefined
|
||||
// Detect arrays of objects for structured editing
|
||||
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
|
||||
|
||||
if (isObjectArray) {
|
||||
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
|
||||
const cols = Object.keys(rows[0] || {})
|
||||
|
||||
return (
|
||||
<div key={a.id} className="border border-white/[0.06] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-white/[0.02]">
|
||||
<div>
|
||||
<span className="text-sm text-white/90">{a.label_en || a.label_de}</span>
|
||||
<span className="text-xs text-white/40 font-mono ml-2">{a.key}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
cols.forEach(c => { newRow[c] = typeof rows[0][c] === 'number' ? 0 : '' })
|
||||
const updated = [...rows, newRow]
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="text-[10px] px-2 py-1 rounded bg-white/[0.06] text-white/60 hover:text-white hover:bg-white/[0.1]"
|
||||
>
|
||||
+ Row
|
||||
</button>
|
||||
{isEdited && (
|
||||
<button
|
||||
onClick={() => saveAssumption(a)}
|
||||
disabled={savingId === a.id}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] px-2.5 py-1 rounded flex items-center gap-1 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" /> Save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
{cols.map(c => (
|
||||
<th key={c} className="text-left py-2 px-3 text-white/40 font-medium uppercase tracking-wider">{c}</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
{cols.map(c => (
|
||||
<td key={c} className="py-1.5 px-3">
|
||||
<input
|
||||
type={typeof row[c] === 'number' ? 'number' : 'text'}
|
||||
value={row[c] as string | number}
|
||||
onChange={e => {
|
||||
const updated = rows.map((r, i) => {
|
||||
if (i !== ri) return r
|
||||
const val = typeof r[c] === 'number' ? Number(e.target.value) || 0 : e.target.value
|
||||
return { ...r, [c]: val }
|
||||
})
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono py-0.5 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5 px-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = rows.filter((_, i) => i !== ri)
|
||||
setEdit(a.id, JSON.stringify(updated))
|
||||
}}
|
||||
className="text-white/30 hover:text-rose-400 p-1"
|
||||
title="Remove row"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentValue = isEdited
|
||||
? edits[a.id]
|
||||
: a.value_type === 'timeseries'
|
||||
: typeof a.value === 'object'
|
||||
? JSON.stringify(a.value)
|
||||
: String(a.value)
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ interface InvestorDetail {
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
version_status: string | null
|
||||
}
|
||||
sessions: Array<{
|
||||
id: string
|
||||
@@ -60,6 +63,11 @@ export default function InvestorDetailPage() {
|
||||
const [company, setCompany] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed')))
|
||||
}, [])
|
||||
|
||||
function flashToast(msg: string) {
|
||||
setToast(msg)
|
||||
@@ -236,6 +244,40 @@ export default function InvestorDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version assignment */}
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={inv.assigned_version_id || ''}
|
||||
onChange={async (e) => {
|
||||
const versionId = e.target.value || null
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assigned_version_id: versionId }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) { flashToast('Version updated'); load() }
|
||||
else { flashToast('Update failed') }
|
||||
}}
|
||||
disabled={busy}
|
||||
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="">Default (base tables)</option>
|
||||
{versions.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-white/40">
|
||||
{inv.assigned_version_id
|
||||
? `Investor sees version "${inv.version_name || ''}"`
|
||||
: 'Investor sees default pitch data'}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audit log for this investor */}
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface Investor {
|
||||
created_at: string
|
||||
slides_viewed: number
|
||||
last_activity: string | null
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Logins</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Slides</th>
|
||||
<th className="py-3 px-4 font-medium">Version</th>
|
||||
<th className="py-3 px-4 font-medium">Last login</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
|
||||
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
|
||||
<td className="py-3 px-4">
|
||||
{inv.version_name ? (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
|
||||
) : (
|
||||
<span className="text-xs text-white/30">Default</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
|
||||
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface FieldDiff {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}
|
||||
|
||||
interface RowDiff {
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
fields: FieldDiff[]
|
||||
}
|
||||
|
||||
interface TableDiff {
|
||||
tableName: string
|
||||
rows: RowDiff[]
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
interface DiffData {
|
||||
versionA: { id: string; name: string }
|
||||
versionB: { id: string; name: string }
|
||||
diffs: TableDiff[]
|
||||
total_changes: number
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
added: 'bg-green-500/10 border-green-500/20',
|
||||
removed: 'bg-rose-500/10 border-rose-500/20',
|
||||
changed: 'bg-amber-500/10 border-amber-500/20',
|
||||
}
|
||||
|
||||
export default function DiffPage() {
|
||||
const { id, otherId } = useParams<{ id: string; otherId: string }>()
|
||||
const [data, setData] = useState<DiffData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !otherId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/admin/versions/${id}/diff/${otherId}`)
|
||||
.then(r => r.json())
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, otherId])
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
if (!data) return <div className="text-rose-400">Failed to load diff</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to version
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
|
||||
<p className="text-sm text-white/50">
|
||||
<span className="text-indigo-300">{data.versionA.name}</span>
|
||||
{' → '}
|
||||
<span className="text-purple-300">{data.versionB.name}</span>
|
||||
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.diffs.length === 0 ? (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
|
||||
No differences found
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.diffs.map(table => (
|
||||
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
|
||||
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
|
||||
<span className="text-xs text-white/40">
|
||||
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-5 pb-4 space-y-2">
|
||||
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
|
||||
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
|
||||
row.status === 'added' ? 'text-green-300' :
|
||||
row.status === 'removed' ? 'text-rose-300' :
|
||||
'text-amber-300'
|
||||
}`}>{row.status}</span>
|
||||
</div>
|
||||
{row.fields.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{row.fields.map(f => (
|
||||
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
|
||||
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
|
||||
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
|
||||
<span className="col-span-1 text-white/30 text-center">→</span>
|
||||
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
524
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal file
524
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
|
||||
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
|
||||
import FormField from '@/components/pitch-admin/editors/FormField'
|
||||
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
|
||||
import RowTable from '@/components/pitch-admin/editors/RowTable'
|
||||
import CardList from '@/components/pitch-admin/editors/CardList'
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
|
||||
competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
|
||||
metrics: 'Metrics', funding: 'Funding', products: 'Products',
|
||||
fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
|
||||
}
|
||||
const TABLE_NAMES = Object.keys(TABLE_LABELS)
|
||||
|
||||
interface Version {
|
||||
id: string; name: string; description: string | null
|
||||
status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
|
||||
}
|
||||
|
||||
type R = Record<string, unknown>
|
||||
|
||||
export default function VersionEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [version, setVersion] = useState<Version | null>(null)
|
||||
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
|
||||
const [activeTab, setActiveTab] = useState('company')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [jsonMode, setJsonMode] = useState(false)
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}`)
|
||||
if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
|
||||
setLoading(false)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (id) load() }, [id, load])
|
||||
|
||||
// Sync JSON text when switching tabs or toggling JSON mode
|
||||
useEffect(() => {
|
||||
if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
|
||||
}, [activeTab, jsonMode, allData])
|
||||
|
||||
function updateData(newData: unknown[]) {
|
||||
setAllData(prev => ({ ...prev, [activeTab]: newData }))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
function updateRecord(index: number, key: string, value: unknown) {
|
||||
const arr = [...(allData[activeTab] as R[] || [])]
|
||||
arr[index] = { ...arr[index], [key]: value }
|
||||
updateData(arr)
|
||||
}
|
||||
|
||||
// For single-record tables (company, funding)
|
||||
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
|
||||
|
||||
async function saveTable() {
|
||||
let data: unknown
|
||||
if (jsonMode) {
|
||||
try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
|
||||
} else {
|
||||
data = allData[activeTab]
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
setSaving(false)
|
||||
if (res.ok) {
|
||||
setDirty(false)
|
||||
if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
|
||||
flashToast('Saved')
|
||||
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
|
||||
}
|
||||
|
||||
async function commitVersion() {
|
||||
if (!confirm('Commit this version? It becomes immutable.')) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
|
||||
if (res.ok) { flashToast('Committed'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function forkVersion() {
|
||||
const name = prompt('Name for the new draft:')
|
||||
if (!name) return
|
||||
const res = await fetch(`/api/admin/versions/${id}/fork`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
|
||||
})
|
||||
if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
if (!version) return <div className="text-rose-400">Version not found</div>
|
||||
|
||||
const isDraft = version.status === 'draft'
|
||||
const data = allData[activeTab] || []
|
||||
const single = (data as R[])[0] || {} as R
|
||||
|
||||
function renderEditor() {
|
||||
if (jsonMode) {
|
||||
return (
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
|
||||
readOnly={!isDraft}
|
||||
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
|
||||
style={{ minHeight: '400px' }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (activeTab) {
|
||||
case 'company':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
|
||||
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
|
||||
</div>
|
||||
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
|
||||
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
|
||||
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'team':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="role_en"
|
||||
addLabel="Add team member"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
|
||||
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
|
||||
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
|
||||
</div>
|
||||
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'financials':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'year', label: 'Year', type: 'number' },
|
||||
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
|
||||
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
|
||||
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
|
||||
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
|
||||
{ key: 'customers_count', label: 'Customers', type: 'number' },
|
||||
{ key: 'employees_count', label: 'Employees', type: 'number' },
|
||||
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
|
||||
]}
|
||||
addLabel="Add year"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'market':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'market_segment', label: 'Segment' },
|
||||
{ key: 'label', label: 'Label' },
|
||||
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
|
||||
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'competitors':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="website"
|
||||
addLabel="Add competitor"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
|
||||
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
|
||||
</div>
|
||||
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
|
||||
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'features':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="feature_name_en"
|
||||
subtitleKey="category"
|
||||
addLabel="Add feature"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
|
||||
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
|
||||
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
|
||||
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
|
||||
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'milestones':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="title_en"
|
||||
subtitleKey="milestone_date"
|
||||
addLabel="Add milestone"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
|
||||
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
|
||||
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
|
||||
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
|
||||
]} />
|
||||
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metrics':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="metric_name"
|
||||
subtitleKey="value"
|
||||
addLabel="Add metric"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
|
||||
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
|
||||
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
|
||||
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'funding':
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
|
||||
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
|
||||
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
|
||||
</div>
|
||||
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
|
||||
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
|
||||
]} />
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
|
||||
<RowTable
|
||||
rows={(single.use_of_funds as R[]) || []}
|
||||
onChange={v => updateSingle('use_of_funds', v)}
|
||||
columns={[
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'percentage', label: '%', type: 'number' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'products':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="hardware"
|
||||
addLabel="Add product"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
|
||||
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
|
||||
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
|
||||
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
|
||||
</div>
|
||||
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
|
||||
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
|
||||
</div>
|
||||
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_scenarios':
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CardList
|
||||
items={data as R[]}
|
||||
onChange={updateData}
|
||||
titleKey="name"
|
||||
subtitleKey="description"
|
||||
addLabel="Add scenario"
|
||||
renderCard={(item, update) => (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
|
||||
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
|
||||
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'fm_assumptions':
|
||||
// Reuse the inline table approach from the FM editor (already works well for this)
|
||||
return (
|
||||
<div className="p-4">
|
||||
<RowTable
|
||||
rows={data as R[]}
|
||||
onChange={updateData}
|
||||
columns={[
|
||||
{ key: 'key', label: 'Key' },
|
||||
{ key: 'label_de', label: 'Label DE' },
|
||||
{ key: 'label_en', label: 'Label EN' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'unit', label: 'Unit' },
|
||||
]}
|
||||
addLabel="Add assumption"
|
||||
/>
|
||||
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="p-4 text-white/40">No editor for this table</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to versions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
|
||||
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
|
||||
}`}>{version.status}</span>
|
||||
</div>
|
||||
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/pitch-preview/${id}`}
|
||||
target="_blank"
|
||||
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" /> Preview
|
||||
</Link>
|
||||
{isDraft && (
|
||||
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" /> Commit
|
||||
</button>
|
||||
)}
|
||||
<button onClick={forkVersion} className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<GitFork className="w-4 h-4" /> Fork
|
||||
</button>
|
||||
{version.parent_id && (
|
||||
<Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
|
||||
Diff
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{TABLE_NAMES.map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
|
||||
activeTab === t
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
{TABLE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
|
||||
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setJsonMode(!jsonMode)}
|
||||
className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
|
||||
jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
<Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
|
||||
</button>
|
||||
{isDraft && (
|
||||
<button
|
||||
onClick={saveTable}
|
||||
disabled={saving || !dirty}
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderEditor()}
|
||||
</div>
|
||||
|
||||
{!isDraft && <p className="text-xs text-white/30 text-center">Committed — read-only. Fork to edit.</p>}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal file
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface VersionOption {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export default function NewVersionPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [parentId, setParentId] = useState<string>('')
|
||||
const [versions, setVersions] = useState<VersionOption[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/versions')
|
||||
.then(r => r.json())
|
||||
.then(d => setVersions(d.versions || []))
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
const res = await fetch('/api/admin/versions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: description || undefined,
|
||||
parent_id: parentId || undefined,
|
||||
}),
|
||||
})
|
||||
setSubmitting(false)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
router.push(`/pitch-admin/versions/${d.version.id}`)
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
setError(d.error || 'Creation failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to versions
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
|
||||
<p className="text-sm text-white/50 mb-6">
|
||||
A new draft will be created with a full copy of all pitch data.
|
||||
Choose a parent to fork from, or leave empty to snapshot the current base tables.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Name <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Conservative Q4, Series A Ready"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional notes about this version"
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={e => setParentId(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
>
|
||||
<option value="">Base tables (current pitch data)</option>
|
||||
{versions.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create draft'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal file
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
parent_id: string | null
|
||||
status: 'draft' | 'committed'
|
||||
created_by_name: string | null
|
||||
created_by_email: string | null
|
||||
committed_at: string | null
|
||||
created_at: string
|
||||
assigned_count: number
|
||||
}
|
||||
|
||||
export default function VersionsPage() {
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/versions')
|
||||
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function commitVersion(id: string) {
|
||||
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Committed'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function forkVersion(id: string) {
|
||||
const name = prompt('Name for the new draft:')
|
||||
if (!name) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Forked'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
async function deleteVersion(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
|
||||
setBusy(id)
|
||||
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
|
||||
setBusy(null)
|
||||
if (res.ok) { flashToast('Deleted'); load() }
|
||||
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
|
||||
<p className="text-sm text-white/50 mt-1">
|
||||
{versions.length} version{versions.length !== 1 ? 's' : ''} — each is a complete snapshot of all pitch data
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/pitch-admin/versions/new"
|
||||
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Version
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
|
||||
<Link
|
||||
href="/pitch-admin/versions/new"
|
||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Create First Version
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.map(v => {
|
||||
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
|
||||
return (
|
||||
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
|
||||
{v.name}
|
||||
</Link>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
|
||||
v.status === 'committed'
|
||||
? 'bg-green-500/15 text-green-300 border-green-500/30'
|
||||
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
|
||||
}`}>
|
||||
{v.status}
|
||||
</span>
|
||||
{v.assigned_count > 0 && (
|
||||
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> {v.assigned_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
|
||||
<div className="flex items-center gap-3 text-xs text-white/40">
|
||||
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
|
||||
<span>{new Date(v.created_at).toLocaleDateString()}</span>
|
||||
{parent && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" /> from {parent.name}
|
||||
</span>
|
||||
)}
|
||||
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Link
|
||||
href={`/pitch-admin/versions/${v.id}`}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Link>
|
||||
{v.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => commitVersion(v.id)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
|
||||
title="Commit"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => forkVersion(v.id)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30"
|
||||
title="Fork"
|
||||
>
|
||||
<GitFork className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteVersion(v.id, v.name)}
|
||||
disabled={busy === v.id}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick diff link if has parent */}
|
||||
{v.parent_id && (
|
||||
<div className="mt-3 pt-3 border-t border-white/[0.04]">
|
||||
<Link
|
||||
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Compare with parent →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal file
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import PitchDeck from '@/components/PitchDeck'
|
||||
|
||||
export default function PreviewPage() {
|
||||
const { versionId } = useParams<{ versionId: string }>()
|
||||
const [data, setData] = useState<PitchData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/preview-data/${versionId}`)
|
||||
.then(async r => {
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
|
||||
return r.json()
|
||||
})
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [versionId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-white/40 text-sm">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
|
||||
<div className="text-center max-w-md px-6">
|
||||
<p className="text-rose-400 mb-2">Preview Error</p>
|
||||
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
|
||||
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
|
||||
// The banner at the top indicates this is a preview
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Preview banner */}
|
||||
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold">
|
||||
PREVIEW MODE — This is how investors will see this version
|
||||
</div>
|
||||
<PitchDeck
|
||||
lang={lang}
|
||||
onToggleLanguage={toggleLanguage}
|
||||
investor={null}
|
||||
onLogout={() => {}}
|
||||
previewData={data}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,10 +47,14 @@ interface PitchDeckProps {
|
||||
onToggleLanguage: () => void
|
||||
investor: Investor | null
|
||||
onLogout: () => void
|
||||
previewData?: PitchData | null
|
||||
}
|
||||
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
|
||||
const { data, loading, error } = usePitchData()
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
|
||||
const fetched = usePitchData()
|
||||
const data = previewData || fetched.data
|
||||
const loading = previewData ? false : fetched.loading
|
||||
const error = previewData ? null : fetched.error
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
GitBranch,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
@@ -22,6 +23,7 @@ interface AdminShellProps {
|
||||
const NAV = [
|
||||
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
|
||||
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
|
||||
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
|
||||
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
|
||||
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
|
||||
@@ -43,7 +45,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
|
||||
<div className="h-screen bg-[#0a0a1a] text-white flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
|
||||
@@ -111,7 +113,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal file
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
|
||||
interface ArrayFieldProps {
|
||||
label: string
|
||||
values: string[]
|
||||
onChange: (v: string[]) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
function add() {
|
||||
const v = input.trim()
|
||||
if (v && !values.includes(v)) {
|
||||
onChange([...values, v])
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
onChange(values.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
|
||||
{v}
|
||||
<button onClick={() => remove(i)} className="hover:text-rose-300">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
|
||||
placeholder={placeholder || 'Type and press Enter'}
|
||||
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
|
||||
/>
|
||||
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface BilingualFieldProps {
|
||||
label: string
|
||||
valueDe: string
|
||||
valueEn: string
|
||||
onChangeDe: (v: string) => void
|
||||
onChangeEn: (v: string) => void
|
||||
multiline?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function BilingualField({
|
||||
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
|
||||
}: BilingualFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">DE</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueDe || ''}
|
||||
onChange={e => onChangeDe(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] text-white/40 font-semibold">EN</span>
|
||||
</div>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={placeholder}
|
||||
className={`${inputClass} resize-none`}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={valueEn || ''}
|
||||
onChange={e => onChangeEn(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal file
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
|
||||
|
||||
interface CardListProps {
|
||||
items: Record<string, unknown>[]
|
||||
onChange: (items: Record<string, unknown>[]) => void
|
||||
titleKey: string
|
||||
subtitleKey?: string
|
||||
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
|
||||
newItemTemplate?: Record<string, unknown>
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function CardList({
|
||||
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
|
||||
}: CardListProps) {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
|
||||
function updateItem(idx: number, key: string, value: unknown) {
|
||||
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const newItem = newItemTemplate || (() => {
|
||||
const template: Record<string, unknown> = {}
|
||||
if (items.length > 0) {
|
||||
Object.keys(items[0]).forEach(k => {
|
||||
const sample = items[0][k]
|
||||
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
|
||||
})
|
||||
}
|
||||
if ('sort_order' in template) template.sort_order = items.length
|
||||
return template
|
||||
})()
|
||||
onChange([...items, newItem])
|
||||
setExpandedIdx(items.length)
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
if (!confirm('Remove this item?')) return
|
||||
onChange(items.filter((_, i) => i !== idx))
|
||||
if (expandedIdx === idx) setExpandedIdx(null)
|
||||
}
|
||||
|
||||
function moveUp(idx: number) {
|
||||
if (idx === 0) return
|
||||
const copy = [...items]
|
||||
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx - 1)
|
||||
}
|
||||
|
||||
function moveDown(idx: number) {
|
||||
if (idx >= items.length - 1) return
|
||||
const copy = [...items]
|
||||
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
|
||||
onChange(copy)
|
||||
setExpandedIdx(idx + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => {
|
||||
const isExpanded = expandedIdx === idx
|
||||
const title = String(item[titleKey] || `Item ${idx + 1}`)
|
||||
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
|
||||
|
||||
return (
|
||||
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-white/30">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); moveUp(idx) }}
|
||||
className="hover:text-white/60 p-0.5"
|
||||
title="Move up"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
|
||||
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
|
||||
</div>
|
||||
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); removeItem(idx) }}
|
||||
className="text-white/30 hover:text-rose-400 p-1"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
|
||||
{renderCard(item, (key, value) => updateItem(idx, key, value))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
onChange: (v: string | number | boolean) => void
|
||||
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
label, value, onChange, type = 'text', placeholder, options, hint,
|
||||
}: FormFieldProps) {
|
||||
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
|
||||
|
||||
{type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
|
||||
/>
|
||||
<span className="text-sm text-white/70">{placeholder || label}</span>
|
||||
</label>
|
||||
) : type === 'select' && options ? (
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : type === 'color' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={String(value) || '#6366f1'}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={String(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={`${inputClass} flex-1`}
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
value={value as string | number}
|
||||
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal file
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
interface RowTableProps {
|
||||
rows: Record<string, unknown>[]
|
||||
onChange: (rows: Record<string, unknown>[]) => void
|
||||
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
|
||||
// Auto-detect columns from first row if not provided
|
||||
const cols = columns || (rows.length > 0
|
||||
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
|
||||
key: k,
|
||||
label: k.replace(/_/g, ' '),
|
||||
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
|
||||
}))
|
||||
: [])
|
||||
|
||||
function updateCell(rowIdx: number, key: string, value: string) {
|
||||
const col = cols.find(c => c.key === key)
|
||||
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
|
||||
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const newRow: Record<string, unknown> = {}
|
||||
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
|
||||
// Carry over id-like fields
|
||||
if (rows.length > 0 && 'id' in rows[0]) {
|
||||
newRow.id = (rows.length + 1)
|
||||
}
|
||||
if (rows.length > 0 && 'sort_order' in rows[0]) {
|
||||
newRow.sort_order = rows.length
|
||||
}
|
||||
onChange([...rows, newRow])
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
onChange(rows.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.08]">
|
||||
{cols.map(c => (
|
||||
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
|
||||
{cols.map(c => (
|
||||
<td key={c.key} className="py-1 px-2">
|
||||
<input
|
||||
type={c.type || 'text'}
|
||||
value={(row[c.key] as string | number) ?? ''}
|
||||
onChange={e => updateCell(ri, c.key, e.target.value)}
|
||||
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1 px-1">
|
||||
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
pitch-deck/lib/version-diff.ts
Normal file
102
pitch-deck/lib/version-diff.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface FieldDiff {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}
|
||||
|
||||
export interface RowDiff {
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
id?: string | number
|
||||
fields: FieldDiff[]
|
||||
before?: Record<string, unknown>
|
||||
after?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TableDiff {
|
||||
tableName: string
|
||||
rows: RowDiff[]
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two arrays of row objects. Matches rows by `id` field if present,
|
||||
* otherwise by array position.
|
||||
*/
|
||||
export function diffTable(
|
||||
tableName: string,
|
||||
before: unknown[],
|
||||
after: unknown[],
|
||||
): TableDiff {
|
||||
const beforeArr = (before || []) as Record<string, unknown>[]
|
||||
const afterArr = (after || []) as Record<string, unknown>[]
|
||||
|
||||
const rows: RowDiff[] = []
|
||||
|
||||
// Build lookup by id if available
|
||||
const hasIds = beforeArr.length > 0 && 'id' in (beforeArr[0] || {})
|
||||
|
||||
if (hasIds) {
|
||||
const beforeMap = new Map(beforeArr.map(r => [String(r.id), r]))
|
||||
const afterMap = new Map(afterArr.map(r => [String(r.id), r]))
|
||||
const allIds = new Set([...beforeMap.keys(), ...afterMap.keys()])
|
||||
|
||||
for (const id of allIds) {
|
||||
const b = beforeMap.get(id)
|
||||
const a = afterMap.get(id)
|
||||
|
||||
if (!b && a) {
|
||||
rows.push({ status: 'added', id: a.id as string, fields: [], after: a })
|
||||
} else if (b && !a) {
|
||||
rows.push({ status: 'removed', id: b.id as string, fields: [], before: b })
|
||||
} else if (b && a) {
|
||||
const fields = diffFields(b, a)
|
||||
rows.push({
|
||||
status: fields.length > 0 ? 'changed' : 'unchanged',
|
||||
id: b.id as string,
|
||||
fields,
|
||||
before: b,
|
||||
after: a,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Positional comparison
|
||||
const maxLen = Math.max(beforeArr.length, afterArr.length)
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const b = beforeArr[i]
|
||||
const a = afterArr[i]
|
||||
if (!b && a) {
|
||||
rows.push({ status: 'added', fields: [], after: a })
|
||||
} else if (b && !a) {
|
||||
rows.push({ status: 'removed', fields: [], before: b })
|
||||
} else if (b && a) {
|
||||
const fields = diffFields(b, a)
|
||||
rows.push({
|
||||
status: fields.length > 0 ? 'changed' : 'unchanged',
|
||||
fields,
|
||||
before: b,
|
||||
after: a,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
rows,
|
||||
hasChanges: rows.some(r => r.status !== 'unchanged'),
|
||||
}
|
||||
}
|
||||
|
||||
function diffFields(before: Record<string, unknown>, after: Record<string, unknown>): FieldDiff[] {
|
||||
const diffs: FieldDiff[] = []
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||
for (const key of allKeys) {
|
||||
const bVal = JSON.stringify(before[key] ?? null)
|
||||
const aVal = JSON.stringify(after[key] ?? null)
|
||||
if (bVal !== aVal) {
|
||||
diffs.push({ key, before: before[key], after: after[key] })
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
76
pitch-deck/lib/version-helpers.ts
Normal file
76
pitch-deck/lib/version-helpers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import pool from './db'
|
||||
|
||||
/**
|
||||
* The 12 data tables tracked per version.
|
||||
* Each maps to a pitch_version_data.table_name value.
|
||||
*/
|
||||
export const VERSION_TABLES = [
|
||||
'company', 'team', 'financials', 'market', 'competitors',
|
||||
'features', 'milestones', 'metrics', 'funding', 'products',
|
||||
'fm_scenarios', 'fm_assumptions',
|
||||
] as const
|
||||
|
||||
export type VersionTableName = typeof VERSION_TABLES[number]
|
||||
|
||||
/** Maps version table names to the actual DB table + ORDER BY */
|
||||
const TABLE_QUERIES: Record<VersionTableName, string> = {
|
||||
company: 'SELECT * FROM pitch_company LIMIT 1',
|
||||
team: 'SELECT * FROM pitch_team ORDER BY sort_order',
|
||||
financials: 'SELECT * FROM pitch_financials ORDER BY year',
|
||||
market: 'SELECT * FROM pitch_market ORDER BY id',
|
||||
competitors: 'SELECT * FROM pitch_competitors ORDER BY id',
|
||||
features: 'SELECT * FROM pitch_features ORDER BY sort_order',
|
||||
milestones: 'SELECT * FROM pitch_milestones ORDER BY sort_order',
|
||||
metrics: 'SELECT * FROM pitch_metrics ORDER BY id',
|
||||
funding: 'SELECT * FROM pitch_funding LIMIT 1',
|
||||
products: 'SELECT * FROM pitch_products ORDER BY sort_order',
|
||||
fm_scenarios: 'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name',
|
||||
fm_assumptions: 'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order',
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot all base tables into pitch_version_data for a given version.
|
||||
*/
|
||||
export async function snapshotBaseTables(versionId: string, adminId: string | null): Promise<void> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
for (const tableName of VERSION_TABLES) {
|
||||
const { rows } = await client.query(TABLE_QUERIES[tableName])
|
||||
await client.query(
|
||||
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (version_id, table_name) DO UPDATE SET data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[versionId, tableName, JSON.stringify(rows), adminId],
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all version data from one version to another.
|
||||
*/
|
||||
export async function copyVersionData(fromVersionId: string, toVersionId: string, adminId: string | null): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
|
||||
SELECT $1, table_name, data, $3
|
||||
FROM pitch_version_data WHERE version_id = $2`,
|
||||
[toVersionId, fromVersionId, adminId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all version data as a map of table_name → JSONB rows.
|
||||
*/
|
||||
export async function loadVersionData(versionId: string): Promise<Record<string, unknown[]>> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
const result: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
result[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const PUBLIC_PATHS = [
|
||||
]
|
||||
|
||||
// Paths gated on the admin session cookie
|
||||
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
|
||||
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
|
||||
|
||||
191
pitch-deck/migrations/000_pitch_data_tables.sql
Normal file
191
pitch-deck/migrations/000_pitch_data_tables.sql
Normal file
@@ -0,0 +1,191 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Core data tables + Financial Model
|
||||
-- Run BEFORE 001_investor_auth.sql
|
||||
-- =========================================================
|
||||
|
||||
-- Company info
|
||||
CREATE TABLE IF NOT EXISTS pitch_company (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
legal_form TEXT,
|
||||
founding_date TEXT,
|
||||
tagline_de TEXT,
|
||||
tagline_en TEXT,
|
||||
mission_de TEXT,
|
||||
mission_en TEXT,
|
||||
website TEXT,
|
||||
hq_city TEXT
|
||||
);
|
||||
|
||||
-- Team members
|
||||
CREATE TABLE IF NOT EXISTS pitch_team (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
role_de TEXT,
|
||||
role_en TEXT,
|
||||
bio_de TEXT,
|
||||
bio_en TEXT,
|
||||
equity_pct NUMERIC,
|
||||
expertise TEXT[],
|
||||
linkedin_url TEXT,
|
||||
photo_url TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Historical financials
|
||||
CREATE TABLE IF NOT EXISTS pitch_financials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INT,
|
||||
revenue_eur BIGINT,
|
||||
costs_eur BIGINT,
|
||||
mrr_eur BIGINT,
|
||||
burn_rate_eur BIGINT,
|
||||
customers_count INT,
|
||||
employees_count INT,
|
||||
arr_eur BIGINT
|
||||
);
|
||||
|
||||
-- Market segments (TAM/SAM/SOM)
|
||||
CREATE TABLE IF NOT EXISTS pitch_market (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_segment TEXT,
|
||||
label TEXT,
|
||||
value_eur BIGINT,
|
||||
growth_rate_pct NUMERIC,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
-- Competitors
|
||||
CREATE TABLE IF NOT EXISTS pitch_competitors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
customers_count INT,
|
||||
pricing_range TEXT,
|
||||
strengths TEXT[],
|
||||
weaknesses TEXT[],
|
||||
website TEXT
|
||||
);
|
||||
|
||||
-- Feature comparison matrix
|
||||
CREATE TABLE IF NOT EXISTS pitch_features (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature_name_de TEXT,
|
||||
feature_name_en TEXT,
|
||||
category TEXT,
|
||||
breakpilot BOOLEAN,
|
||||
proliance BOOLEAN,
|
||||
dataguard BOOLEAN,
|
||||
heydata BOOLEAN,
|
||||
is_differentiator BOOLEAN,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Milestones / timeline
|
||||
CREATE TABLE IF NOT EXISTS pitch_milestones (
|
||||
id SERIAL PRIMARY KEY,
|
||||
milestone_date TEXT,
|
||||
title_de TEXT,
|
||||
title_en TEXT,
|
||||
description_de TEXT,
|
||||
description_en TEXT,
|
||||
status TEXT,
|
||||
category TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Key metrics
|
||||
CREATE TABLE IF NOT EXISTS pitch_metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
metric_name TEXT,
|
||||
label_de TEXT,
|
||||
label_en TEXT,
|
||||
value TEXT,
|
||||
unit TEXT,
|
||||
is_live BOOLEAN
|
||||
);
|
||||
|
||||
-- Funding round
|
||||
CREATE TABLE IF NOT EXISTS pitch_funding (
|
||||
id SERIAL PRIMARY KEY,
|
||||
round_name TEXT,
|
||||
amount_eur BIGINT,
|
||||
use_of_funds JSONB,
|
||||
instrument TEXT,
|
||||
target_date TEXT,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
-- Products / tiers
|
||||
CREATE TABLE IF NOT EXISTS pitch_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
hardware TEXT,
|
||||
hardware_cost_eur NUMERIC,
|
||||
monthly_price_eur NUMERIC,
|
||||
llm_model TEXT,
|
||||
llm_size TEXT,
|
||||
llm_capability_de TEXT,
|
||||
llm_capability_en TEXT,
|
||||
features_de TEXT[],
|
||||
features_en TEXT[],
|
||||
is_popular BOOLEAN,
|
||||
operating_cost_eur NUMERIC,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- =========================================================
|
||||
-- Financial Model
|
||||
-- =========================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_assumptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
|
||||
key TEXT,
|
||||
label_de TEXT,
|
||||
label_en TEXT,
|
||||
value JSONB,
|
||||
value_type TEXT DEFAULT 'scalar',
|
||||
unit TEXT,
|
||||
min_value NUMERIC,
|
||||
max_value NUMERIC,
|
||||
step_size NUMERIC,
|
||||
category TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
|
||||
month INT,
|
||||
year INT,
|
||||
month_in_year INT,
|
||||
new_customers INT,
|
||||
churned_customers INT,
|
||||
total_customers INT,
|
||||
mrr_eur NUMERIC,
|
||||
arr_eur NUMERIC,
|
||||
revenue_eur NUMERIC,
|
||||
cogs_eur NUMERIC,
|
||||
personnel_eur NUMERIC,
|
||||
infra_eur NUMERIC,
|
||||
marketing_eur NUMERIC,
|
||||
total_costs_eur NUMERIC,
|
||||
employees_count INT,
|
||||
gross_margin_pct NUMERIC,
|
||||
burn_rate_eur NUMERIC,
|
||||
runway_months NUMERIC,
|
||||
cac_eur NUMERIC,
|
||||
ltv_eur NUMERIC,
|
||||
ltv_cac_ratio NUMERIC,
|
||||
cash_balance_eur NUMERIC,
|
||||
cumulative_revenue_eur NUMERIC
|
||||
);
|
||||
36
pitch-deck/migrations/003_pitch_versions.sql
Normal file
36
pitch-deck/migrations/003_pitch_versions.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Version Management (Git-Style History)
|
||||
-- =========================================================
|
||||
|
||||
-- Version metadata: each version points to its parent (git-style DAG)
|
||||
CREATE TABLE IF NOT EXISTS pitch_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
parent_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'committed')),
|
||||
created_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
|
||||
committed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_versions_parent ON pitch_versions(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_versions_status ON pitch_versions(status);
|
||||
|
||||
-- Version content: one row per data table per version (fully materialized)
|
||||
-- table_name values: company, team, financials, market, competitors, features,
|
||||
-- milestones, metrics, funding, products, fm_scenarios, fm_assumptions
|
||||
CREATE TABLE IF NOT EXISTS pitch_version_data (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version_id UUID NOT NULL REFERENCES pitch_versions(id) ON DELETE CASCADE,
|
||||
table_name TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
|
||||
UNIQUE(version_id, table_name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_version_data_version ON pitch_version_data(version_id);
|
||||
|
||||
-- Per-investor version assignment (NULL = use base tables)
|
||||
ALTER TABLE pitch_investors
|
||||
ADD COLUMN IF NOT EXISTS assigned_version_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL;
|
||||
Reference in New Issue
Block a user