Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a20c17444 | ||
|
|
3e26009543 | ||
|
|
aca571b2e7 | ||
|
|
06d320908f | ||
|
|
4d5be72ebb | ||
|
|
f665999d81 | ||
|
|
4815a957b0 | ||
|
|
31343d329a |
@@ -22,6 +22,10 @@ RLS_ENFORCED=false
|
|||||||
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
|
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
|
||||||
REDIS_PASSWORD=CHANGE_ME_redis_password
|
REDIS_PASSWORD=CHANGE_ME_redis_password
|
||||||
|
|
||||||
|
# --- Fertige Images (docker-compose.coolify.prebuilt.yml, DEPLOY.md §9) ---
|
||||||
|
REGISTRY=git.certvia.de/msolarczek
|
||||||
|
IMAGE_TAG=main
|
||||||
|
|
||||||
# --- Objektspeicher (Service "garage", S3-kompatibel) ---
|
# --- Objektspeicher (Service "garage", S3-kompatibel) ---
|
||||||
# Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern
|
# Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern
|
||||||
# der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der
|
# der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der
|
||||||
@@ -60,7 +64,11 @@ AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32
|
|||||||
# PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt.
|
# PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt.
|
||||||
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
|
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
|
||||||
# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern)
|
# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern)
|
||||||
AUTH_URL=http://REPLACE-WITH-COOLIFY-SSLIP-DOMAIN
|
AUTH_URL=https://app.craftvia.de
|
||||||
|
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
|
||||||
|
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
|
||||||
|
# Leer = alles auf einer Domain.
|
||||||
|
MARKETING_HOST=craftvia.de
|
||||||
# Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost:
|
# Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost:
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
|
|||||||
# MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern.
|
# MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern.
|
||||||
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
|
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
|
||||||
AUTH_URL=https://app.craftvia.example
|
AUTH_URL=https://app.craftvia.example
|
||||||
|
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
|
||||||
|
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
|
||||||
|
# Leer = alles auf einer Domain.
|
||||||
|
MARKETING_HOST=craftvia.example
|
||||||
# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig.
|
# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig.
|
||||||
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
|
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
|
||||||
# WEBAUTHN_ORIGIN=https://app.craftvia.example
|
# WEBAUTHN_ORIGIN=https://app.craftvia.example
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
|
|||||||
| Code | HTTP | Bedeutung / `details` |
|
| Code | HTTP | Bedeutung / `details` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
|
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
|
||||||
| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request |
|
| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request; Modul/Funktion nicht im Paket (L17: `details = { reason: "not_in_plan", module?, message }`) |
|
||||||
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
|
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
|
||||||
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
|
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
|
||||||
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
|
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
|
||||||
@@ -40,6 +40,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
|
|||||||
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
|
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
|
||||||
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
|
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
|
||||||
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
|
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
|
||||||
|
- L17 Pakete: Operationen eines Moduls außerhalb des Pakets bzw. eines ausgeschalteten Moduls (`emergency.create` → Notdienst, `milestone.reach` → Abrechnung) werden gespeichert als `rejected` mit `errorCode = "forbidden"` und `message = "not_in_plan: <Klartext>"` bzw. `"module_disabled: <Klartext>"`.
|
||||||
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
|
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
|
||||||
|
|
||||||
## Uploads
|
## Uploads
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-
|
|||||||
Proxies dürfen kein niedrigeres Body-Limit haben.
|
Proxies dürfen kein niedrigeres Body-Limit haben.
|
||||||
- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH.
|
- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH.
|
||||||
|
|
||||||
|
### 2.1 Website und App auf getrennten Domains
|
||||||
|
|
||||||
|
Ein `app`-Container bedient beide Domains; der Proxy trennt sie (`src/lib/marketing/hosts.ts`,
|
||||||
|
Test `scripts/test-host-routing.ts`):
|
||||||
|
|
||||||
|
| Domain | zeigt | leitet um |
|
||||||
|
|---|---|---|
|
||||||
|
| `craftvia.de` | `/`, `/funktionen/**`, `/sicherheit`, `/preise`, `/impressum` | alles andere (Login, `/testen`, App, API) → `app.craftvia.de` (308) |
|
||||||
|
| `www.craftvia.de` | – | → `craftvia.de` (308) |
|
||||||
|
| `app.craftvia.de` | die Anwendung; `/` ohne Sitzung → `/login` | Produktseiten → `craftvia.de` (308) |
|
||||||
|
|
||||||
|
- Env: `AUTH_URL=https://app.craftvia.de`, `MARKETING_HOST=craftvia.de` (leer = eine Domain für alles).
|
||||||
|
- Coolify, Service `app`, Feld *Domains*: `https://app.craftvia.de:3000,https://craftvia.de:3000,https://www.craftvia.de:3000`.
|
||||||
|
- DNS: `craftvia.de` und `app.craftvia.de` als A/AAAA auf den Server, `www.craftvia.de` als CNAME auf `craftvia.de`.
|
||||||
|
- Die Links der Produktseiten bleiben relativ (`/login`, `/testen`); die Umleitung übernimmt der Proxy.
|
||||||
|
So steckt keine Domain im Image, und dieselben Images laufen auf Test und Produktion.
|
||||||
|
- Sitzungs-Cookies gelten nur für `app.craftvia.de`. Interne Aufrufe (Healthcheck über `127.0.0.1`)
|
||||||
|
werden nicht umgeleitet.
|
||||||
|
|
||||||
## 3. Ersteinrichtung (Coolify)
|
## 3. Ersteinrichtung (Coolify)
|
||||||
|
|
||||||
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
|
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
|
||||||
@@ -217,6 +236,43 @@ verarbeitet `tenant-export` (Datenexport). Details, Schreibsperre und manueller
|
|||||||
[TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und
|
[TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und
|
||||||
Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen.
|
Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen.
|
||||||
|
|
||||||
|
### 7.5 Pakete Basis/Profi und Lotse-Chat-Plätze (L17)
|
||||||
|
|
||||||
|
Die App rechnet nicht ab – sie setzt das Paket durch, verwaltet Chat-Plätze und zählt die Nutzung,
|
||||||
|
damit der Betreiber außerhalb der App die Rechnung stellt. Regeln: `src/lib/plans.ts`, Durchsetzung:
|
||||||
|
`src/server/plan.ts`.
|
||||||
|
|
||||||
|
| Paket | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Basis** | Aufträge, Kunden, Objekte, Teams, mobile App/Offline, Berichte + Unterschrift + PDF-Versand, Zeiterfassung inkl. Freigabe, Dokumente, Benachrichtigungen – **ohne KI und Planung** |
|
||||||
|
| **Profi** (Default) | zusätzlich Planung (Plantafel, Live-Lage, Kapazität, Empfehlungen, Verzugs-/Frühfertig-Hinweise), Abrechnungsübersicht, Notdienst, Auftragsimport, Lotse (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung) und – je Platz – der Lotse-Chat |
|
||||||
|
|
||||||
|
**Paket, Plätze, hartes Limit setzen** (nur Plattform-Voll-Admins): Betreiber-Portal → Mandant
|
||||||
|
öffnen → Karte „Paket & Lotse-Chat“ → „Paket ändern …“ → Paket, Anzahl gebuchter Chat-Plätze
|
||||||
|
(0–1000) und „Hartes Limit“ wählen, Bestätigung ankreuzen, speichern. Jede Änderung steht im
|
||||||
|
Audit-Trail des Mandanten (`tenant_plan`, Scope Plattform, vorher/nachher). Die Mandantenliste zeigt
|
||||||
|
das Paket als Badge. Mandanten-Admins sehen das Paket nur lesend und vergeben unter
|
||||||
|
`/settings/lotse` die gebuchten Plätze an Nutzer mit Feldrolle (`lotse:use` + `field:execute`);
|
||||||
|
mehr als gebucht ist serverseitig ausgeschlossen. Senkt der Betreiber die Anzahl unter die vergebenen
|
||||||
|
Plätze, gelten nur die zuerst vergebenen; der Admin sieht „Über der Platzanzahl“ und entzieht Plätze.
|
||||||
|
|
||||||
|
**Wechsel Profi → Basis:** Daten bleiben vollständig erhalten (Einsätze, Abrechnungseinträge, Plätze,
|
||||||
|
Chatverläufe), nur der Zugriff endet: Seiten leiten auf das Dashboard mit dem Hinweis „Im Paket Profi
|
||||||
|
enthalten“, Actions/API antworten `403` (`details.reason = "not_in_plan"`), Offline-Operationen eines
|
||||||
|
gesperrten Moduls (Notdienst, Meilensteine) werden mit Klartext abgewiesen. Zurück auf Profi ist alles
|
||||||
|
wieder da. **Testphase** (`plan = TRIAL`) läuft immer als Profi ohne Platzprüfung.
|
||||||
|
|
||||||
|
**Kontingent und Mehrverbrauch ablesen:** Ein Chat = eine Nachricht eines Nutzers an den Lotsen. Je
|
||||||
|
vergebenem Platz 150 Chats pro Kalendermonat (Zeitzone des Mandanten), gemeinsam für den Betrieb
|
||||||
|
(Testphase: berechtigte Nutzer × 150). Darüber wird weitergezählt; abgerechnet wird der Mehrverbrauch in
|
||||||
|
Paketen à 100 (angefangene Pakete zählen). Die Karte im Betreiber-Portal zeigt für den **laufenden
|
||||||
|
Monat und den Vormonat** „x von y Chats“, den Mehrverbrauch und die Anzahl der 100er-Pakete – das ist
|
||||||
|
die Grundlage der Rechnung (Vormonat nach Monatsende ablesen). Mit „Hartes Limit“ wird der Chat beim
|
||||||
|
Kontingent gesperrt (Monteure sehen einen Hinweis statt des Eingabefelds, kein Mehrverbrauch). Die
|
||||||
|
Token-Obergrenze (`AI_MONTHLY_TOKEN_LIMIT`, `services/lotse/budget.ts`) bleibt als Sicherheitsnetz
|
||||||
|
unabhängig davon aktiv. Hinweis: Chatverläufe werden nach `AI_GENERATION_RETENTION_DAYS` gelöscht –
|
||||||
|
den Wert über ~62 Tagen lassen, sonst fehlen Vormonats-Chats in der Zählung.
|
||||||
|
|
||||||
## 8. Rate Limits
|
## 8. Rate Limits
|
||||||
|
|
||||||
| Bereich | Env | Default | Zählung |
|
| Bereich | Env | Default | Zählung |
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Lane L17 – Pakete (Basis/Profi) & Lotse-Chat pro Nutzer (`lane/pakete`)
|
||||||
|
|
||||||
|
Stand: 2026-09-21 · Basis `4d5be72` (`feature/craftvia-mvp`) · Betriebsdoku: [../DEPLOY.md §7.5](../DEPLOY.md)
|
||||||
|
|
||||||
|
Ziel: Zwei Pakete durchsetzen und den Lotse-Chat als Zusatzmodul pro Nutzer mit gemeinsamem Kontingent führen. **Keine Abrechnung in der App** – die App setzt Pakete durch, verwaltet Plätze und zählt Nutzung; der Betreiber stellt die Rechnung außerhalb.
|
||||||
|
|
||||||
|
## 1. Umfang / umgesetzte Entscheidungen
|
||||||
|
|
||||||
|
| Punkt | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| **1 Datenmodell** | Migration `20260921100000_pakete` (additiv): `enum TenantTier { BASIS PROFI }`, am `Tenant` `tier` (Default `PROFI` → alle Bestandsmandanten und Testphasen unverändert), `lotseChatSeats Int @default(0)`, `lotseChatHardLimit Boolean @default(false)` (Plattform-Daten, keine RLS). Neue Mandanten-Tabelle `lotse_chat_seats` (`LotseChatSeat`: tenantId, userId, assignedById, createdAt; unique tenantId+userId) mit `enable_tenant_rls`, in beiden `TENANT_MODELS`, `userId`/`assignedById` in `pii-fields.ts`. Index `lotse_messages(tenant_id, role, created_at)` für die Monatszählung. |
|
||||||
|
| **2 Zentrale Regeln** `src/lib/plans.ts` (client-sicher, rein) | Stufen, `PROFI_ONLY_MODULES = billing, emergency, imports, lotse`, Features ohne eigenes Modul `planning`, `lotse_chat`; `effectiveTier` (Testphase → Profi), `isModuleInTier`, `isFeatureInTier`, `LOTSE_CHAT_CHATS_PER_SEAT = 150`, `LOTSE_CHAT_OVERAGE_PACK = 100`, `chatQuota`, `chatOverage`, `overagePacks` (angefangene Pakete zählen), `usageLevel` (ok / warn ab 80 % / exhausted / over), `usagePercent`, `isQuotaBlocked`, Monatsschlüssel. |
|
||||||
|
| **3 Durchsetzung an einer Stelle** `src/server/plan.ts` | `moduleState(tenantId, key)` = `not_in_plan` (Stufe) → `disabled` (TenantModule) → `enabled`; `effectiveModules` (Navigation/Übersichten), `isModuleActive`, `assertPlanFeature`/`requirePlanFeature` (Planung). Genutzt von **`requireModule`** (Layouts: nicht im Paket → `/dashboard?module=profi` mit ruhigem Hinweis „Im Paket Profi enthalten“), **`assertModuleEnabled`** (moduleGuard + `requireApiContext`; `ModuleDisabledError.reason = "not_in_plan"`, Audit `denied`, API → `403` mit `details.reason = "not_in_plan"` + Klartext), **Sync** (`services/sync/apply.ts#OP_MODULES`: `emergency.create` → emergency, `milestone.reach` → billing; gesperrt → gespeichert `rejected`/`forbidden` mit `not_in_plan: <Klartext>` bzw. `module_disabled: …`, Gerät zeigt eigenen Text `offline.problem.notInPlan|moduleDisabled`), **Navigation** (Backoffice-Sidebar über `effectiveModules` + `NavItem.feature`, mobile Bottom-Navigation ohne „Notdienst“/„Lotse“), **`isLotseEnabled`** (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung, Transkription), **Planung** (`planningAccess` → Plantafel, Live-Lage, Kapazität, Empfehlungen, Frühfertig, Dashboard-Kacheln; zusätzlich `scheduleWorkOrder`, `updateTeamPlanningSettings`, `runPlanningWatch` überspringt Basis; Planungs-Layout `requirePlanFeature`). Abrechnungs-Schalter (`billingModuleActive`, Auftragsdetail) laufen ebenfalls über `plan.ts` – Basis verhält sich wie „Abrechnungsübersicht deaktiviert“ (direktes „abgerechnet“, Tab ausgeblendet). |
|
||||||
|
| **4 Lotse-Chat-Plätze** | `services/lotse/chat/access.ts` ist die eine Prüfstelle: Rechte → Stufe (`blocked not_in_plan`) → Lotse an (`disabled`) → Chat-Schalter (`chat_disabled`) → Platz (`blocked no_seat`, Testphase ohne Platzprüfung) → beim **Senden** Kontingent mit hartem Limit (`blocked quota_exhausted`). `canUseLotseChat` (Navigation, „Lotse fragen“) schließt den Platz ein. `seats.ts`: berechtigt = aktive Nutzer mit `lotse:use` + `field:execute` (DB-Rollen), Vergabe/Entzug `setChatSeat` (tenant:manage, Zod, Audit `lotse_chat_seat` before/after, idempotent, serialisiert über eine Zeilensperre der Mandanten-Einstellungen → parallele Vergaben überschreiten die Grenze nicht). Gültig sind die ältesten `lotseChatSeats` Zuweisungen (Überbelegung nach Senkung durch den Betreiber sichtbar). `usage.ts`: Chats im Monat gesamt und je Nutzer (Kalendermonat in der Mandanten-Zeitzone, DST-sicher), Kontingent = gezählte Plätze × 150 (Testphase: Berechtigte × 150, kein hartes Limit), Mehrverbrauch, 100er-Pakete, Stufe, Sperre. Die Token-Obergrenze (`budget.ts`) bleibt unverändert als Sicherheitsnetz. |
|
||||||
|
| **5 Mandanten-Admin** `/settings/lotse` | Neue Karte „Paket & Lotse-Chat“: Paket nur lesend (Badge, Testphase-Hinweis), „x von y Plätzen vergeben“, Liste aller Nutzer mit Feldrolle mit Schalter je Nutzer (Button `role="switch"`, ≥ 44 px, Status als Text + Icon, deaktiviert ohne freien Platz – serverseitig ohnehin geprüft), Monatsverbrauch als Balken (`role="meter"`) mit Text „212 von 450 Chats“, Hinweis ab 80 %, bei ausgeschöpftem Kontingent / Mehrverbrauch (inkl. Paketzahl) / hartem Limit, Aufschlüsselung je Nutzer. Basis: Hinweis, Plätze bleiben gespeichert. Action `actions/lotse/seats.ts` (`moduleGuard("lotse")` + `guard("tenant:manage")`). |
|
||||||
|
| **6 Mobil** | Ohne Platz kein Eintrag „Lotse“ in der Bottom-Navigation und kein „Lotse fragen“; `/m/lotse` zeigt „Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.“ Basis: Hinweis statt Weiterleitung (Lotse-Layout). Hartes Limit erreicht: Hinweis statt Eingabefeld (Verlauf bleibt lesbar, Chips deaktiviert); auch nach einer abgewiesenen Nachricht. |
|
||||||
|
| **7 Betreiber** `/admin/[id]` | Karte „Paket & Lotse-Chat“: Stufe, Plätze vergeben/gebucht, hartes Limit, Chats laufender Monat und Vormonat („x von y Chats“), Mehrverbrauch → 100er-Pakete (Grundlage der Rechnung). „Paket ändern …“ (nur Voll-Admins) → Popup mit Stufe, Platzanzahl (0–1000), hartem Limit und Pflicht-Bestätigung; `services/plans/platform.ts#updateTenantPlan` prüft den Akteur gegen den PlatformAdmin-Store, Zod, Plattform-Audit `tenant_plan` (before/after). Mandantenliste: Spalte „Paket“ als Badge; Modul-Popup markiert Module außerhalb der Stufe („Nur im Paket Profi“). |
|
||||||
|
| **8 Demo-Seed** | `demo` = Profi mit 3 Plätzen für Max, Nora, Paul (Monteure; Chat funktioniert weiter), `demo2` = Basis (zeigt die Sperren). Idempotent (`seedDemoPlan`). |
|
||||||
|
| **9 i18n/Doku** | Namespace `plans` (de/en), Ergänzungen in `lotse.json` (`chat.errors.not_in_plan/no_seat/quota_exhausted`, `chat.quotaExhausted`) und `offline.json` (`problem.notInPlan/moduleDisabled`); `DEPLOY.md §7.5` (wie der Betreiber Pakete/Plätze setzt, wie Mehrverbrauch abgelesen wird), `API.md` (403-Details, Sync-Abweisung). |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
- **Admin als Platzinhaber:** Berechtigung = Rechte `lotse:use` + `field:execute` (nicht der Rollenname). Der Mandantenadministrator hat alle Rechte und kann sich daher selbst einen Platz geben; Backoffice nicht.
|
||||||
|
- **Kontingent „je vergebenem Platz“:** zählt vergebene Plätze, höchstens die gebuchte Anzahl.
|
||||||
|
- **Sync-Abweisung ist endgültig** (gespeichert, wie andere deterministische Ablehnungen). Nach einem Upgrade muss der Monteur den Notdienst erneut erfassen.
|
||||||
|
- **Hartes Limit gilt nicht in der Testphase** (keine gebuchten Plätze; Token-Budget bleibt).
|
||||||
|
- **Basis sperrt auch die Platzvergabe** (moduleGuard `lotse`), Plätze bleiben gespeichert.
|
||||||
|
|
||||||
|
## 2. Dateien
|
||||||
|
|
||||||
|
**Neu**
|
||||||
|
- `prisma/migrations/20260921100000_pakete/migration.sql`
|
||||||
|
- `src/lib/plans.ts`, `src/server/plan.ts`
|
||||||
|
- `src/server/services/lotse/chat/{seats,usage}.ts`, `src/server/services/plans/platform.ts`
|
||||||
|
- `src/server/actions/lotse/seats.ts`, `src/server/actions/plans-platform.ts`
|
||||||
|
- `src/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx`, `src/components/lotse/chat/{seat-admin,unavailable}.tsx`
|
||||||
|
- `messages/{de,en}/plans.json`, dieser Bericht
|
||||||
|
- Tests: `scripts/test-pakete-{rules,gates,seats}.ts`
|
||||||
|
|
||||||
|
**Eingriffe in Fundament-/Fremddateien (klein, markiert „L17“)**
|
||||||
|
|
||||||
|
| Datei | Eingriff | Grund |
|
||||||
|
|---|---|---|
|
||||||
|
| `prisma/schema.prisma` | 3 Tenant-Felder, Enum, Modell `LotseChatSeat`, Index an `LotseMessage` | Punkt 1 |
|
||||||
|
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `LotseChatSeat` | Pflicht neue Tenant-Tabelle |
|
||||||
|
| `src/server/modules.ts` | `requireModule`/`assertModuleEnabled` über `moduleState`; `ModuleDisabledError.reason` | zentrale Durchsetzung |
|
||||||
|
| `src/server/api/respond.ts` | 403 mit `details.reason = not_in_plan` | Klartext für Clients |
|
||||||
|
| `src/lib/nav.ts`, `src/app/(app)/layout.tsx` | `NavItem.feature`, `lockedFeatures`; effektive Module | Navigation |
|
||||||
|
| `src/app/(app)/dashboard/page.tsx` (L2) | Hinweis `?module=profi`, Kacheln Abrechnung/Notdienst nur bei aktivem Modul | ruhiger Hinweis |
|
||||||
|
| `src/app/(app)/settings/page.tsx` | Modulübersicht über effektive Module | Anzeige |
|
||||||
|
| `src/app/(app)/work-orders/[id]/page.tsx`, `src/server/services/work-orders/transition.ts` (L2/L14) | `billingModuleActive` über `plan.ts`, Tab „Meilensteine & Abrechnung“ nur bei aktiver Abrechnung | Basis = Abrechnung aus |
|
||||||
|
| `src/server/services/planning/{access,schedule,team-settings,watch}.ts`, `src/app/(app)/planning/layout.tsx` (L13) | Feature-Prüfung `planning` | Planung nur Profi |
|
||||||
|
| `src/server/services/sync/apply.ts` (L4), `src/lib/offline/outbox-core.ts` (L7), `messages/{de,en}/offline.json` | `OP_MODULES` + Klartext; `problemKey` | Sync-Sperre |
|
||||||
|
| `src/app/(field)/m/layout.tsx`, `src/components/field/bottom-nav.tsx` (L4) | Notdienst-Eintrag nur bei aktivem Modul, Spaltenzahl dynamisch | mobile Navigation |
|
||||||
|
| `src/server/services/lotse/{settings.ts,chat/access.ts,chat/engine.ts,chat/transcribe.ts}`, `src/lib/lotse/chat.ts`, `src/server/actions/lotse/_chat-state.ts`, `src/components/lotse/chat/lotse-chat.tsx`, `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` (L9/L16) | Stufe in `isLotseEnabled`, Platz/Kontingent im Chat-Gate, Hinweise, Admin-Karte | Punkte 4–6 |
|
||||||
|
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Karte, Modul-Popup-Markierung | Punkt 7 |
|
||||||
|
| `src/components/audit-trail.tsx` | Labels `lotse_chat_seat`, `tenant_plan` | Audit-Viewer |
|
||||||
|
| `scripts/check-module-guards.ts` | `plans-platform.ts` EXEMPT | Top-Level-Action |
|
||||||
|
| `scripts/lib/demo-seed.ts` | `seedDemoPlan`, demo Profi + 3 Plätze, demo2 Basis | Punkt 8 |
|
||||||
|
| `scripts/lib/e2e-fixture.ts`, `scripts/test-e2e-tenant-isolation.ts` | Cleanup + Fixture-Zeile `LotseChatSeat` | Test verlangt alle Tenant-Modelle |
|
||||||
|
| `scripts/lib/lotse-chat-fixture.ts` (L16) | 10 Plätze + Sitz je Testnutzer | bestehende Chat-Tests brauchen jetzt einen Platz |
|
||||||
|
| `scripts/smoke-auth.ts` | L17-Prüfungen (Admin-Karte, Teamleiterin ohne Platz, Monteur mit Platz, demo2 Basis) | Smoke |
|
||||||
|
| `docs/craftvia/{DEPLOY,API}.md` | §7.5, 403/Sync | Betrieb |
|
||||||
|
|
||||||
|
Keine neuen npm-Abhängigkeiten, keine Änderung an `rbac.ts`, keine neuen env-Variablen.
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
| Skript | Prüfungen | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `test-pakete-rules.ts` | 51 | Stufen, Testphase = Profi, Module/Features je Stufe, Kontingent/Mehrverbrauch/100er-Pakete, Warnstufen, hartes Limit, Monatsschlüssel, Navigation Basis/Profi |
|
||||||
|
| `test-pakete-gates.ts` | 74 | Default Profi; Layout-Guard-Funktionen (`moduleState`, `requirePlanFeature` → Redirect `?module=profi`, Quelltextnachweis `requireModule`/Planungs-Layout); Action (`assertModuleEnabled` → `not_in_plan` + Audit) für billing/emergency/imports/lotse, Basis-Module frei; API 403 mit Klartext, Planungs-API 403; Planung (Plantafel, Live-Lage, Einplanen, Empfehlungen, Kapazität, Dashboard-Kachel, Watch) gesperrt / Profi erlaubt; `isLotseEnabled`, Abrechnungsschalter, Navigation; Sync: `emergency.create`/`milestone.reach` → rejected mit Klartext, gespeichert, kein Auftrag, Duplikat, Gerätetext; Profi → angewendet; TenantModule aus + Profi → gesperrt (Action, Sync); nur Betreiber ändert die Stufe (Mandanten-Admin, Nur-Lesen-Admin → forbidden, ungültige Werte → invalid, Mandanten-Einstellungen ändern keine Stufe); Wechsel Profi → Basis → Profi ohne Datenverlust mit Plattform-Audit; Mandantentrennung |
|
||||||
|
| `test-pakete-seats.ts` | 68 | berechtigte Nutzer; ohne Platz → `no_seat` (View, Senden, nichts gespeichert); Vergabe bis zur Grenze, darüber `invalid no_seats_left`, parallele Vergabe (genau eine), idempotent, Audit create/delete; mit Platz → ok/Senden; Entzug; Überbelegung nach Senkung; Feldrollen/Backoffice → forbidden (Vergabe, Übersicht, Verbrauch); ohne Feldrolle / deaktiviert → `not_eligible`; Mandantentrennung (B vergibt/entzieht nichts von A, sieht keine Plätze/Zählung); Monatsgrenze in Europe/Berlin inkl. Sommerzeit, Antworten zählen nicht, je Nutzer; Mehrverbrauch weiterzählen (305/300 → 1 Paket), hartes Limit → `quota_exhausted` ohne Speichern, Verlauf lesbar, B unberührt; Testphase ohne Platzprüfung (Kontingent Berechtigte × 150, kein hartes Limit); Basis sperrt Chat und Vergabe, Plätze bleiben; Betreiber-Übersicht laufender Monat/Vormonat |
|
||||||
|
|
||||||
|
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in fremden Dateien), build inkl. Guard-Check (42 Action-Dateien), **87/87 Testskripte** (vorher 84 + 3 neue). Bestehende Tests unverändert grün; angepasst wurde nur die L16-Chat-Fixture (Plätze).
|
||||||
|
|
||||||
|
**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_pakete`, Rolle `craftvia_app`): `test-pakete-gates`, `test-pakete-seats`, `test-e2e-tenant-isolation`, `test-rls-enforcement`, `test-lotse-chat-engine` – 5/5 grün.
|
||||||
|
|
||||||
|
**HTTP-Smoke** (`next build && next start -p 3117`, `scripts/smoke-auth.ts` ohne Passworteingabe): **152/155** – alle neuen L17-Prüfungen grün (Admin: Karte „Paket & Lotse-Chat“, „3 von 3 Plätzen vergeben“; Teamleiterin ohne Platz: kein Nav-Eintrag, Hinweis „frag im Büro nach“, kein „Lotse fragen“; Monteur mit Platz: Chat, Nav-Einträge Lotse/Notdienst; demo2 Basis: Planung/Abrechnung/Import/Notdienst-Prüfung → `/dashboard?module=profi` mit Hinweis und ohne gesperrte Links, `/api/v1/planning/board` und `/api/v1/billing` → 403). Die **3 Fehlschläge sind vorbestehend**: demo2-Seiten `/dashboard`, `/work-orders`, `/customers` enthalten „Elbblick“, weil `messages/*/marketing.json` (Produktseite, Commits `31343d3`/`4815a95`) den Beispieltext „Wohnanlage Elbblick“ enthält und die Messages an den Client serialisiert werden – kein Datenleck, aber die Isolationsprüfung schlägt an. Server danach beendet.
|
||||||
|
|
||||||
|
**Visuell:** nicht im Browser geprüft (Anmeldung hätte Passwort- oder Token-Eingabe erfordert). Bitte `/settings/lotse` (1024/768 px), `/admin/<id>` (Karte + Popup) und `/m/lotse` ohne Platz / bei hartem Limit (375 px) ansehen.
|
||||||
|
|
||||||
|
## 4. Offene Punkte
|
||||||
|
|
||||||
|
1. **Smoke-Isolationsprüfung „Elbblick“** (vorbestehend, s. o.): Beispieltext in `marketing.json` umbenennen oder die Prüfung auf Daten-Marker umstellen.
|
||||||
|
2. **Vormonat mit heutiger Platzzahl:** Die Betreiber-Karte rechnet das Kontingent des Vormonats mit den *aktuell* vergebenen Plätzen (keine Historie der Platzanzahl). Ändert sich die Platzzahl im Monat, ist der Vormonatswert eine Näherung → ggf. monatlichen Snapshot (Tabelle) nachziehen.
|
||||||
|
3. **Aufbewahrung:** Die Zählung basiert auf `LotseMessage`; `AI_GENERATION_RETENTION_DAYS` muss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung.
|
||||||
|
4. **Hartes Limit unter Parallelität:** geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
|
||||||
|
5. **Worker:** `import-extraction` prüft die Stufe nicht (nur die Testphasen-Sperre). Nach einem Wechsel auf Basis laufen bereits eingereihte Importe noch durch; neue Importe sind gesperrt. Transkription ist über `isLotseEnabled` gesperrt.
|
||||||
|
6. **Abrechnungseinträge im Hintergrund:** Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
|
||||||
|
7. **Sync-Abweisung endgültig:** Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
|
||||||
|
8. `services/emergency/sync-ops.ts` prüft den Modulschalter weiterhin selbst (redundant zur zentralen Sync-Prüfung, unverändert).
|
||||||
|
|
||||||
|
## 5. Screens / Routen
|
||||||
|
|
||||||
|
| Route | Zugriff | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `/dashboard?module=profi` | Backoffice | Hinweis „Im Paket Profi enthalten“ (Ziel aller gesperrten Profi-Seiten) |
|
||||||
|
| `/settings/lotse` | `tenant:manage` | Karte „Paket & Lotse-Chat“: Paket, Plätze, Schalter je Nutzer, Monatsverbrauch |
|
||||||
|
| `/m/lotse` | Feldrollen | ohne Platz / Basis: Hinweis; hartes Limit: Hinweis statt Eingabefeld |
|
||||||
|
| `/admin` | Plattform | Spalte „Paket“ |
|
||||||
|
| `/admin/<id>?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ |
|
||||||
@@ -317,11 +317,15 @@
|
|||||||
"transition_forbidden": "Dafür fehlt die Berechtigung.",
|
"transition_forbidden": "Dafür fehlt die Berechtigung.",
|
||||||
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
|
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
|
||||||
"reason_required": "Der Grund fehlt.",
|
"reason_required": "Der Grund fehlt.",
|
||||||
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich."
|
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich.",
|
||||||
|
"not_in_plan": "Der Lotse ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet.",
|
||||||
|
"no_seat": "Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.",
|
||||||
|
"quota_exhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht."
|
||||||
},
|
},
|
||||||
"unavailable": {
|
"unavailable": {
|
||||||
"title": "Lotse-Chat nicht verfügbar",
|
"title": "Lotse-Chat nicht verfügbar",
|
||||||
"back": "Zur Übersicht"
|
"back": "Zur Übersicht"
|
||||||
}
|
},
|
||||||
|
"quotaExhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht. Ab dem nächsten Monat kannst du wieder schreiben – bei Fragen hilft das Büro."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "Craftvia — Einsatz-App für Handwerk und Montage",
|
||||||
|
"description": "Aufträge, Planung, Zeiterfassung, Berichte und Abrechnung in einem System. Mobil, offline-fähig und mandantensicher — für Handwerks- und Montagebetriebe."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"label": "Hauptnavigation",
|
||||||
|
"features": "Funktionen",
|
||||||
|
"planning": "Planung",
|
||||||
|
"mobile": "Baustelle",
|
||||||
|
"security": "Sicherheit",
|
||||||
|
"pricing": "Testphase",
|
||||||
|
"faq": "Fragen",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"cta": "Kostenlos testen",
|
||||||
|
"menu": "Menü"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": "Für Handwerks- und Montagebetriebe",
|
||||||
|
"title": "Handwerk. Digital auf Kurs.",
|
||||||
|
"subtitle": "Craftvia führt Auftrag, Planung, Zeiten, Material und Bericht an einer Stelle zusammen — im Büro am Rechner, auf der Baustelle am Telefon. Auch ohne Netz.",
|
||||||
|
"ctaPrimary": "14 Tage kostenlos testen",
|
||||||
|
"ctaSecondary": "Funktionen ansehen",
|
||||||
|
"bullet1": "In wenigen Minuten eingerichtet, ohne Installation",
|
||||||
|
"bullet2": "Offline nutzbar — Eingaben gehen nie verloren",
|
||||||
|
"bullet3": "Keine Zahlungsdaten für den Test nötig"
|
||||||
|
},
|
||||||
|
"pillars": {
|
||||||
|
"title": "Was Craftvia zusammenhält",
|
||||||
|
"one": {
|
||||||
|
"title": "Ein Auftrag, eine Wahrheit",
|
||||||
|
"text": "Termin, Team, Zeiten, Material, Fotos und Bericht hängen am selben Auftrag — kein Zettel, keine Doppelerfassung."
|
||||||
|
},
|
||||||
|
"two": {
|
||||||
|
"title": "Büro und Baustelle im Takt",
|
||||||
|
"text": "Das Büro plant, der Monteur arbeitet ab. Beide sehen denselben Stand, ohne Anruf."
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"title": "Abschluss statt Nacharbeit",
|
||||||
|
"text": "Pflichtangaben, Fotos und Unterschrift werden vor Ort geprüft — der Auftrag ist fertig, wenn der Monteur vom Hof fährt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "Alles, was ein Einsatz braucht",
|
||||||
|
"sub": "Module, die zusammenarbeiten. Sie schalten frei, was Sie brauchen.",
|
||||||
|
"orders": {
|
||||||
|
"title": "Aufträge",
|
||||||
|
"text": "Vom Anruf bis zur Abnahme: anlegen, einplanen, zuweisen, Status jederzeit sehen. Eigene Auftragsarten mit Checklisten und Pflichtfotos."
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"title": "Plantafel",
|
||||||
|
"text": "Aufträge per Maus auf Team und Tag ziehen. Auslastung, Überbuchung und Überschneidung sind sofort sichtbar."
|
||||||
|
},
|
||||||
|
"live": {
|
||||||
|
"title": "Live-Lage",
|
||||||
|
"text": "Wer ist unterwegs, wer arbeitet, wer macht Pause — am Einsatzort des laufenden Auftrags. Ohne GPS-Ortung der Person."
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"title": "Zeiterfassung",
|
||||||
|
"text": "Start, Pause, Ende mit einem Tipp. Fahrzeit und Arbeitszeit getrennt, Nachträge nur mit Begründung und Freigabe."
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "Mobil und offline",
|
||||||
|
"text": "Im Keller, in der Tiefgarage, im Funkloch: Eingaben werden lokal gespeichert und automatisch nachgesendet."
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Bericht und Unterschrift",
|
||||||
|
"text": "Leistungsnachweis auf dem Gerät ausfüllen, Kunde unterschreibt direkt auf dem Display, das PDF geht an den Kunden."
|
||||||
|
},
|
||||||
|
"material": {
|
||||||
|
"title": "Material",
|
||||||
|
"text": "Geplantes und tatsächlich verbautes Material erfassen, Abweichungen mit Grund — sauber für die Nachkalkulation."
|
||||||
|
},
|
||||||
|
"billing": {
|
||||||
|
"title": "Abrechnungsübersicht",
|
||||||
|
"text": "Fertige Aufträge und erreichte Meilensteine laufen gesammelt auf: Zeiten, Material und Anfahrten als Grundlage für die Rechnung."
|
||||||
|
},
|
||||||
|
"emergency": {
|
||||||
|
"title": "Notdienst",
|
||||||
|
"text": "Einsatz in Minuten anlegen, Pflichtangaben später nachziehen, Prüfung im Büro."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"title": "Lotse — der Assistent",
|
||||||
|
"text": "Der Monteur schreibt oder spricht wie im Chat. Der Lotse ordnet zu und fragt Fehlendes nach. Gebucht wird erst nach Bestätigung."
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"title": "Dokumente",
|
||||||
|
"text": "Pläne, Fotos und Datenblätter am Auftrag und am Objekt — auch unterwegs verfügbar."
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"title": "Auftragsimport",
|
||||||
|
"text": "Aufträge aus PDF einlesen: Daten werden vorgeschlagen und vor der Übernahme geprüft."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planning": {
|
||||||
|
"eyebrow": "Planung",
|
||||||
|
"title": "Die Woche im Blick, bevor sie schiefgeht",
|
||||||
|
"text": "Die Plantafel zeigt jede Kolonne mit ihrer Tageskapazität. Wird ein Tag zu voll oder überschneiden sich zwei Termine, steht es als Meldung auf der Karte — nicht erst im Anruf des Kunden.",
|
||||||
|
"point1": "Auslastung je Team und Tag in Prozent",
|
||||||
|
"point2": "Warnung bei Überbuchung, Überschneidung und unvollständiger Kolonne",
|
||||||
|
"point3": "Hinweis, wenn ein Einsatz länger dauert als geplant — und der Folgeauftrag wackelt",
|
||||||
|
"point4": "Vorschläge für Aufträge in der Nähe, wenn ein Team früher fertig ist"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"eyebrow": "Auf der Baustelle",
|
||||||
|
"title": "Vier Tipps, und der Einsatz ist dokumentiert",
|
||||||
|
"text": "Die mobile Ansicht ist für Handschuhe und helles Licht gebaut: große Flächen, klare Schritte, keine Suche im Menü.",
|
||||||
|
"step1": {
|
||||||
|
"title": "Anfahrt starten",
|
||||||
|
"text": "Fahrzeit läuft automatisch mit."
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Vor Ort arbeiten",
|
||||||
|
"text": "Checkliste abhaken, Fotos aufnehmen, Material buchen."
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"title": "Bericht ausfüllen",
|
||||||
|
"text": "Text diktieren oder tippen, Pflichtangaben werden geprüft."
|
||||||
|
},
|
||||||
|
"step4": {
|
||||||
|
"title": "Unterschrift holen",
|
||||||
|
"text": "Kunde unterschreibt auf dem Display, PDF geht raus."
|
||||||
|
},
|
||||||
|
"offlineNote": "Kein Netz? Alles läuft weiter und wird später automatisch übertragen."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"eyebrow": "Lotse",
|
||||||
|
"title": "Sagen, was war — statt Felder suchen",
|
||||||
|
"text": "Der Lotse versteht ganze Sätze, ordnet den Auftrag zu und fragt nach, was fehlt. Er zeigt eine Karte mit dem, was er buchen würde. Erst nach Bestätigung wird etwas geschrieben.",
|
||||||
|
"chatUser": "Auftrag fertig, Speicher eingebaut, 2 Stunden, einen Filter verbraucht",
|
||||||
|
"chatAssistant": "Ich ordne das dem Auftrag A-00042 zu, Wohnanlage Lindenhof. Zwei Fragen: War die Anfahrt dabei, und soll ich den Auftrag technisch abschließen?",
|
||||||
|
"cardTitle": "Vorschlag",
|
||||||
|
"cardLine1": "2:00 Std. Arbeitszeit buchen",
|
||||||
|
"cardLine2": "1 × Filter als Material erfassen",
|
||||||
|
"cardLine3": "Auftrag technisch abschließen",
|
||||||
|
"cardConfirm": "Bestätigen",
|
||||||
|
"cardDiscard": "Verwerfen",
|
||||||
|
"note": "Rechte, Pflichtangaben und Freigaben gelten unverändert — der Assistent umgeht nichts."
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"title": "In drei Schritten startklar",
|
||||||
|
"one": {
|
||||||
|
"title": "Konto anlegen",
|
||||||
|
"text": "Firma eintragen, Testzeitraum wählen, E-Mail bestätigen. Auf Wunsch mit Beispieldaten zum Ausprobieren."
|
||||||
|
},
|
||||||
|
"two": {
|
||||||
|
"title": "Team und Kunden anlegen",
|
||||||
|
"text": "Monteure einladen, Kolonnen bilden, Kunden und Objekte erfassen oder importieren."
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"title": "Ersten Auftrag fahren",
|
||||||
|
"text": "Auftrag einplanen, Monteur arbeitet ihn mobil ab, Bericht kommt zurück ins Büro."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"industries": {
|
||||||
|
"title": "Für Betriebe, die rausfahren",
|
||||||
|
"sub": "Gebaut für Montage und Service — unabhängig vom Gewerk.",
|
||||||
|
"shk": "Sanitär, Heizung, Klima",
|
||||||
|
"electric": "Elektrotechnik",
|
||||||
|
"cooling": "Kälte- und Lüftungstechnik",
|
||||||
|
"lifts": "Aufzüge, Tore, Antriebe",
|
||||||
|
"windows": "Fenster, Türen, Fassade",
|
||||||
|
"service": "Industrie- und Anlagenservice"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"eyebrow": "Sicherheit und Datenschutz",
|
||||||
|
"title": "Ihre Daten bleiben Ihre Daten",
|
||||||
|
"text": "Craftvia ist mandantenfähig gebaut: Jeder Betrieb sieht ausschließlich seine eigenen Daten — abgesichert bis in die Datenbank, nicht nur in der Oberfläche.",
|
||||||
|
"item1": {
|
||||||
|
"title": "Strikte Mandantentrennung",
|
||||||
|
"text": "Zugriffsregeln direkt in der Datenbank verhindern, dass Daten den Betrieb verlassen."
|
||||||
|
},
|
||||||
|
"item2": {
|
||||||
|
"title": "Rollen und Rechte",
|
||||||
|
"text": "Admin, Backoffice, Teamleitung und Monteur sehen genau das, was sie brauchen. Serverseitig geprüft."
|
||||||
|
},
|
||||||
|
"item3": {
|
||||||
|
"title": "Zwei-Faktor und Passkeys",
|
||||||
|
"text": "Anmeldung mit zweitem Faktor oder Passkey, erzwingbar für den ganzen Betrieb."
|
||||||
|
},
|
||||||
|
"item4": {
|
||||||
|
"title": "Lückenloses Protokoll",
|
||||||
|
"text": "Jede schreibende Aktion wird revisionssicher protokolliert — wer, wann, was."
|
||||||
|
},
|
||||||
|
"item5": {
|
||||||
|
"title": "Export jederzeit",
|
||||||
|
"text": "Ihre Daten lassen sich als Datei herunterladen — auch am Ende der Testphase."
|
||||||
|
},
|
||||||
|
"item6": {
|
||||||
|
"title": "Sparsam mit Daten",
|
||||||
|
"text": "Standort heißt Einsatzort des laufenden Auftrags, keine Ortung von Personen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trial": {
|
||||||
|
"eyebrow": "Testphase",
|
||||||
|
"title": "14 Tage testen, in Ruhe entscheiden",
|
||||||
|
"text": "Sie legen den Testzeitraum selbst fest und starten mit Beispieldaten oder leer. Nach Ablauf bleiben Ihre Daten lesbar und lassen sich exportieren — nichts verschwindet über Nacht.",
|
||||||
|
"point1": "Voller Funktionsumfang, keine Zahlungsdaten nötig",
|
||||||
|
"point2": "Erinnerung vor Ablauf, Verlängerung jederzeit möglich",
|
||||||
|
"point3": "Übernahme in die Vollversion ohne Datenumzug",
|
||||||
|
"cta": "Testphase starten",
|
||||||
|
"secondary": "Angebot anfragen",
|
||||||
|
"note": "Preise der Vollversion auf Anfrage — wir melden uns mit einem Vorschlag, der zur Betriebsgröße passt."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Häufige Fragen",
|
||||||
|
"q1": {
|
||||||
|
"q": "Brauchen meine Monteure eine Schulung?",
|
||||||
|
"a": "Nein. Die mobile Ansicht führt durch den Einsatz: anfahren, arbeiten, dokumentieren, unterschreiben lassen. Die meisten Betriebe starten nach einer kurzen Einweisung."
|
||||||
|
},
|
||||||
|
"q2": {
|
||||||
|
"q": "Funktioniert die App ohne Internet?",
|
||||||
|
"a": "Ja. Eingaben, Fotos und Zeiten werden auf dem Gerät gespeichert und automatisch übertragen, sobald wieder Verbindung besteht."
|
||||||
|
},
|
||||||
|
"q3": {
|
||||||
|
"q": "Muss ich etwas installieren?",
|
||||||
|
"a": "Nein. Craftvia läuft im Browser und lässt sich auf dem Telefon wie eine App auf den Startbildschirm legen."
|
||||||
|
},
|
||||||
|
"q4": {
|
||||||
|
"q": "Was passiert nach der Testphase?",
|
||||||
|
"a": "Ihre Daten bleiben lesbar und exportierbar. Sie können verlängern oder in die Vollversion wechseln — ohne Umzug der Daten."
|
||||||
|
},
|
||||||
|
"q5": {
|
||||||
|
"q": "Kann ich bestehende Kunden und Aufträge übernehmen?",
|
||||||
|
"a": "Ja. Kunden und Objekte lassen sich importieren, Aufträge zusätzlich aus PDF einlesen und vor der Übernahme prüfen."
|
||||||
|
},
|
||||||
|
"q6": {
|
||||||
|
"q": "Werden meine Monteure per GPS überwacht?",
|
||||||
|
"a": "Nein. Angezeigt wird der Einsatzort des laufenden Auftrags, nicht die Position des Geräts oder der Person."
|
||||||
|
},
|
||||||
|
"q7": {
|
||||||
|
"q": "Ersetzt Craftvia meine Buchhaltung?",
|
||||||
|
"a": "Nein. Craftvia bereitet die Abrechnung auf — Zeiten, Material, Anfahrten und Meilensteine. Die Rechnung schreibt Ihre Buchhaltung."
|
||||||
|
},
|
||||||
|
"q8": {
|
||||||
|
"q": "Wie viele Nutzer sind möglich?",
|
||||||
|
"a": "Vom Ein-Mann-Betrieb bis zu mehreren Kolonnen. Teams, Rollen und Kapazitäten legen Sie selbst fest."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"closing": {
|
||||||
|
"title": "Bereit für den ersten Auftrag?",
|
||||||
|
"text": "Richten Sie Ihren Testzugang ein und planen Sie noch heute die erste Woche.",
|
||||||
|
"cta": "Kostenlos testen",
|
||||||
|
"login": "Ich habe schon ein Konto"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "Die Einsatz-App für Handwerks- und Montagebetriebe.",
|
||||||
|
"product": "Produkt",
|
||||||
|
"company": "Rechtliches",
|
||||||
|
"features": "Funktionen",
|
||||||
|
"trial": "Testphase",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"terms": "Nutzungsbedingungen",
|
||||||
|
"privacy": "Datenschutz",
|
||||||
|
"imprint": "Impressum",
|
||||||
|
"rights": "Alle Rechte vorbehalten."
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"planung": {
|
||||||
|
"title": "Planung und Plantafel",
|
||||||
|
"lead": "Die Woche im Blick: Aufträge auf Kolonnen verteilen, Auslastung sehen, Konflikte erkennen, bevor der Kunde anruft.",
|
||||||
|
"p1": "Aufträge per Maus auf Team und Tag ziehen — oder über „Einplanen …“ ganz ohne Maus",
|
||||||
|
"p2": "Auslastung je Kolonne und Tag in Prozent, gemessen am Kolonnentag",
|
||||||
|
"p3": "Meldung bei Überbuchung, Überschneidung und unvollständiger Kolonne",
|
||||||
|
"p4": "Ungeplante Aufträge in einer eigenen Spalte, mit Vorschlägen direkt an der Karte",
|
||||||
|
"p5": "Live-Lage zeigt, wer unterwegs ist, arbeitet oder Pause macht",
|
||||||
|
"shot1": "Plantafel mit fünf Tagen, Auslastung je Kolonne und markierten Konflikten",
|
||||||
|
"shot2": "Live-Lage als Kachelansicht: Kolonnen mit Status, Auftrag und Ort",
|
||||||
|
"c1title": "Kolonnen statt Einzelpersonen",
|
||||||
|
"c1text": "Ein Team fährt zusammen. Die Kapazität ist der Kolonnentag, nicht die Summe der Personenstunden.",
|
||||||
|
"c2title": "Früher fertig, sinnvoll genutzt",
|
||||||
|
"c2text": "Endet ein Einsatz früher, schlägt Craftvia Aufträge in der Nähe vor — vorziehen entscheidet der Mensch.",
|
||||||
|
"c3title": "Verzug sichtbar machen",
|
||||||
|
"c3text": "Läuft ein Einsatz aus dem Ruder, markiert die Tafel den Folgeauftrag als gefährdet."
|
||||||
|
},
|
||||||
|
"baustelle": {
|
||||||
|
"title": "Auf der Baustelle",
|
||||||
|
"lead": "Die mobile Ansicht führt durch den Einsatz: anfahren, arbeiten, dokumentieren, unterschreiben lassen — auch ohne Netz.",
|
||||||
|
"p1": "Große Flächen und klare Schritte, bedienbar mit Arbeitshandschuhen",
|
||||||
|
"p2": "Checkliste, Pflichtfotos und Material direkt am Auftrag",
|
||||||
|
"p3": "Offline nutzbar: Eingaben werden gespeichert und automatisch nachgesendet",
|
||||||
|
"p4": "Unterschrift des Kunden auf dem Display, PDF geht anschließend raus",
|
||||||
|
"p5": "Notdiensteinsätze in Minuten anlegen und später vervollständigen",
|
||||||
|
"shot1": "Tagesansicht des Monteurs mit laufender Zeiterfassung",
|
||||||
|
"shot2": "Auftrag mobil: Status, Checkliste, Material und Fotos an einer Stelle",
|
||||||
|
"c1title": "Kein Zettel mehr",
|
||||||
|
"c1text": "Was auf der Baustelle erfasst wird, steht sofort im Büro zur Verfügung.",
|
||||||
|
"c2title": "Pflichtangaben vor dem Abschluss",
|
||||||
|
"c2text": "Fehlende Fotos oder Unterschriften fallen vor Ort auf, nicht Tage später.",
|
||||||
|
"c3title": "Sync ohne Nachdenken",
|
||||||
|
"c3text": "Die App überträgt selbstständig, sobald wieder Verbindung besteht."
|
||||||
|
},
|
||||||
|
"zeiterfassung": {
|
||||||
|
"title": "Zeiterfassung",
|
||||||
|
"lead": "Start, Pause, Ende mit einem Tipp. Fahrzeit und Arbeitszeit getrennt, Nachträge nachvollziehbar.",
|
||||||
|
"p1": "Laufende Uhr auf jeder Seite sichtbar, „Für heute beenden“ mit einem Tipp",
|
||||||
|
"p2": "Automatischer Wechsel, wenn auf einem anderen Auftrag gestartet wird",
|
||||||
|
"p3": "Zeiten nachtragen für die letzten Tage — mit Begründung",
|
||||||
|
"p4": "Freigabe durch Teamleitung oder Backoffice; offene Einträge zählen nicht für die Abrechnung",
|
||||||
|
"p5": "Backoffice kann Zeiten für Mitarbeitende erfassen, protokolliert im Audit",
|
||||||
|
"shot1": "Meine Zeiten: Tagesübersicht mit Fahrt- und Arbeitszeit",
|
||||||
|
"shot2": "Freigabe im Büro: offene Zeiteinträge prüfen und bestätigen",
|
||||||
|
"c1title": "Ehrliche Stunden",
|
||||||
|
"c1text": "Erfasst wird beim Arbeiten, nicht abends aus dem Gedächtnis.",
|
||||||
|
"c2title": "Korrekturen bleiben sichtbar",
|
||||||
|
"c2text": "Jeder Nachtrag trägt Grund und Freigabe — sauber für Lohn und Nachkalkulation.",
|
||||||
|
"c3title": "Fahrzeit sauber getrennt",
|
||||||
|
"c3text": "Anfahrten zählen eigenständig und landen als Position in der Abrechnung."
|
||||||
|
},
|
||||||
|
"berichte": {
|
||||||
|
"title": "Berichte und Unterschrift",
|
||||||
|
"lead": "Der Leistungsnachweis entsteht auf der Baustelle — geprüft, unterschrieben und als PDF beim Kunden.",
|
||||||
|
"p1": "Bericht direkt am Auftrag, mit Vorlage je Auftragsart",
|
||||||
|
"p2": "Pflichtangaben und Pflichtfotos werden vor dem Einreichen geprüft",
|
||||||
|
"p3": "Unterschrift auf dem Display, alternativ „Kunde nicht angetroffen“ mit Grund",
|
||||||
|
"p4": "Freigabe im Büro, danach automatischer Versand als PDF",
|
||||||
|
"p5": "Jede Version bleibt erhalten, ältere werden als ersetzt gekennzeichnet",
|
||||||
|
"shot1": "Berichte im Büro: Status von eingereicht bis freigegeben",
|
||||||
|
"shot2": "Auftragsdetail mit Zeiten, Material, Fotos und Bericht",
|
||||||
|
"c1title": "Ein Nachweis, keine Sammlung",
|
||||||
|
"c1text": "Bericht, Fotos, Zeiten und Material hängen am selben Auftrag.",
|
||||||
|
"c2title": "Nachvollziehbar",
|
||||||
|
"c2text": "Wer eingereicht, geprüft und freigegeben hat, steht im Protokoll.",
|
||||||
|
"c3title": "Direkt beim Kunden",
|
||||||
|
"c3text": "Nach der Freigabe geht das PDF automatisch per Mail raus."
|
||||||
|
},
|
||||||
|
"abrechnung": {
|
||||||
|
"title": "Abrechnungsübersicht",
|
||||||
|
"lead": "Fertige Aufträge und erreichte Meilensteine laufen gesammelt auf — mit Zeiten, Material und Anfahrten für die Buchhaltung.",
|
||||||
|
"p1": "Abgeschlossene Aufträge, Meilensteine und freigegebene Tagesberichte an einer Stelle",
|
||||||
|
"p2": "Nur freigegebene Zeiten zählen; offene Einträge werden getrennt ausgewiesen",
|
||||||
|
"p3": "Anfahrten aus der Zeiterfassung, je Kolonne und Tag gezählt",
|
||||||
|
"p4": "Abrechnungsblatt als PDF, „Abgerechnet“ mit optionaler Rechnungsnummer",
|
||||||
|
"p5": "Stornieren nur mit Begründung; abgerechnete Positionen bleiben eindeutig zugeordnet",
|
||||||
|
"shot1": "Abrechnungsübersicht mit offenen und abgerechneten Einträgen",
|
||||||
|
"c1title": "Keine doppelte Abrechnung",
|
||||||
|
"c1text": "Jede Zeit und jedes Material gehört zu genau einem abgerechneten Abschnitt.",
|
||||||
|
"c2title": "Buchhaltung bleibt Buchhaltung",
|
||||||
|
"c2text": "Craftvia liefert die Grundlage — Preise, Steuern und Rechnungen macht Ihr System.",
|
||||||
|
"c3title": "Meilensteine statt Endabrechnung",
|
||||||
|
"c3text": "Bei langen Projekten wird abgerechnet, was erreicht ist."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"title": "Lotse — der Assistent",
|
||||||
|
"lead": "Sagen, was war: Der Lotse ordnet zu, fragt nach, was fehlt, und schlägt Buchungen vor. Gespeichert wird erst nach Bestätigung.",
|
||||||
|
"p1": "Schreiben oder sprechen wie im Chat, auch mitten im Einsatz",
|
||||||
|
"p2": "Ordnet den Auftrag selbst zu — aus dem Kontext, der laufenden Uhr oder dem Tagesplan",
|
||||||
|
"p3": "Fragt fehlende Angaben nach, statt zu raten",
|
||||||
|
"p4": "Zeigt Aktionskarten für Status, Zeiten, Material und Notizen",
|
||||||
|
"p5": "Rechte, Pflichtangaben und Freigaben gelten unverändert",
|
||||||
|
"shot1": "Lotse-Chat auf dem Telefon: Eingabe per Text oder Sprache",
|
||||||
|
"c1title": "Bestätigen statt tippen",
|
||||||
|
"c1text": "Der Monteur prüft eine Karte und tippt einmal — statt vier Formulare auszufüllen.",
|
||||||
|
"c2title": "Nichts passiert heimlich",
|
||||||
|
"c2text": "Ohne Bestätigung wird nichts gebucht. Jede Buchung landet im Protokoll.",
|
||||||
|
"c3title": "Auch als Nachschlagewerk",
|
||||||
|
"c3text": "Fragen wie „Wo ist der Zugangscode?“ beantwortet der Lotse aus dem Auftrag."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Funktionen im Überblick",
|
||||||
|
"lead": "Craftvia deckt den ganzen Einsatz ab — vom eingehenden Auftrag bis zur Grundlage für die Rechnung. Jedes Modul lässt sich einzeln freischalten.",
|
||||||
|
"more": "Mehr erfahren"
|
||||||
|
},
|
||||||
|
"securityPage": {
|
||||||
|
"title": "Sicherheit und Datenschutz",
|
||||||
|
"lead": "Craftvia ist mandantenfähig gebaut. Jeder Betrieb sieht ausschließlich seine eigenen Daten — abgesichert bis in die Datenbank, nicht nur in der Oberfläche.",
|
||||||
|
"shot1": "Audit-Protokoll: jede schreibende Aktion mit Zeitpunkt, Person und Änderung",
|
||||||
|
"shot2": "Teams und Rollen: wer welche Aufträge sieht, entscheidet die Zuweisung",
|
||||||
|
"detailTitle": "Was das im Betrieb bedeutet",
|
||||||
|
"d1title": "Feldrollen sehen nur ihre Einsätze",
|
||||||
|
"d1text": "Monteure sehen die Aufträge ihrer Kolonne, nicht die Kundenliste des Betriebs.",
|
||||||
|
"d2title": "Rechte gelten am Server",
|
||||||
|
"d2text": "Jede Änderung wird serverseitig geprüft. Ausgeblendete Schaltflächen sind Komfort, keine Absicherung.",
|
||||||
|
"d3title": "Nachvollziehbar statt vertrauensselig",
|
||||||
|
"d3text": "Das Protokoll hält fest, wer was wann geändert hat — auch für die Buchhaltung."
|
||||||
|
},
|
||||||
|
"pricingPage": {
|
||||||
|
"title": "Testphase und Preise",
|
||||||
|
"lead": "Starten Sie mit einer Testphase und entscheiden Sie danach in Ruhe. Der Preis der Vollversion richtet sich nach der Betriebsgröße.",
|
||||||
|
"trialTitle": "Testphase",
|
||||||
|
"trialText": "Voller Funktionsumfang, Zeitraum selbst gewählt, keine Zahlungsdaten nötig. Nach Ablauf bleiben die Daten lesbar und lassen sich exportieren.",
|
||||||
|
"t1": "Bis zu 30 Tage, Enddatum frei wählbar",
|
||||||
|
"t2": "Mit Beispieldaten oder leer starten",
|
||||||
|
"t3": "Erinnerung vor Ablauf, Verlängerung jederzeit",
|
||||||
|
"fullTitle": "Vollversion",
|
||||||
|
"fullText": "Übernahme ohne Datenumzug: Der Testmandant wird umgestellt, alles bleibt erhalten.",
|
||||||
|
"f1": "Alle Module, unbegrenzte Aufträge",
|
||||||
|
"f2": "Preis nach Anzahl der Nutzer und Kolonnen",
|
||||||
|
"f3": "Angebot nach einem kurzen Gespräch",
|
||||||
|
"note": "Konkrete Preise nennen wir erst, wenn sie feststehen — hier steht bewusst keine Zahl, die später nicht stimmt."
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Craftvia im eigenen Betrieb ausprobieren",
|
||||||
|
"text": "Testzugang einrichten, Beispieldaten laden und die erste Woche planen — in wenigen Minuten.",
|
||||||
|
"primary": "Kostenlos testen",
|
||||||
|
"secondary": "Alle Funktionen ansehen"
|
||||||
|
},
|
||||||
|
"shotFrame": {
|
||||||
|
"label": "Bildschirmfoto aus Craftvia"
|
||||||
|
},
|
||||||
|
"imprint": {
|
||||||
|
"title": "Impressum",
|
||||||
|
"intro": "Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).",
|
||||||
|
"provider": "Anbieter",
|
||||||
|
"represented": "Vertreten durch",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"register": "Registereintrag",
|
||||||
|
"registerCourt": "Registergericht",
|
||||||
|
"registerNumber": "Handelsregister",
|
||||||
|
"vat": "Umsatzsteuer-ID",
|
||||||
|
"vatText": "Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:",
|
||||||
|
"editorial": "Redaktionell verantwortlich",
|
||||||
|
"dispute": "Verbraucherstreitbeilegung / Universalschlichtungsstelle",
|
||||||
|
"disputeText": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,9 @@
|
|||||||
"internal": "Technischer Fehler – wird automatisch erneut versucht.",
|
"internal": "Technischer Fehler – wird automatisch erneut versucht.",
|
||||||
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
|
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
|
||||||
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
|
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
|
||||||
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln."
|
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.",
|
||||||
|
"notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.",
|
||||||
|
"moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden."
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"title": "Offline-Ansicht",
|
"title": "Offline-Ansicht",
|
||||||
|
|||||||
@@ -89,7 +89,8 @@
|
|||||||
"noTeam": "ohne Team",
|
"noTeam": "ohne Team",
|
||||||
"noDate": "ohne Termin",
|
"noDate": "ohne Termin",
|
||||||
"noLocation": "ohne Ortsangabe",
|
"noLocation": "ohne Ortsangabe",
|
||||||
"suggestions": "Vorschläge"
|
"suggestions": "Vorschläge",
|
||||||
|
"suggestionsFor": "Vorschläge für {number}"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Auftrag einplanen",
|
"title": "Auftrag einplanen",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"tier": {
|
||||||
|
"BASIS": "Basis",
|
||||||
|
"PROFI": "Profi"
|
||||||
|
},
|
||||||
|
"colTier": "Paket",
|
||||||
|
"notInTier": "Nur im Paket Profi",
|
||||||
|
"profiHint": {
|
||||||
|
"title": "Im Paket Profi enthalten",
|
||||||
|
"text": "Diese Funktion gehört zum Paket Profi. Ihr Betrieb nutzt das Paket Basis – für ein Upgrade wenden Sie sich bitte an Ihren Craftvia-Ansprechpartner."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Paket & Lotse-Chat",
|
||||||
|
"sub": "Paket des Betriebs, Chat-Plätze für Monteure und Verbrauch im laufenden Monat",
|
||||||
|
"package": "Paket",
|
||||||
|
"trial": "Testphase (alle Funktionen)",
|
||||||
|
"packageReadOnly": "Paket und Anzahl der Chat-Plätze legt der Anbieter fest. Hier vergeben Sie die gekauften Plätze an Ihre Monteure und Teamleiter.",
|
||||||
|
"basisHint": "Lotse und Lotse-Chat sind im Paket Profi enthalten. Bereits vergebene Plätze und Chatverläufe bleiben gespeichert und sind nach einem Wechsel zu Profi wieder verfügbar.",
|
||||||
|
"seatsTitle": "Chat-Plätze",
|
||||||
|
"seatsCount": "{assigned} von {purchased} Plätzen vergeben",
|
||||||
|
"trialSeats": "Während der Testphase dürfen alle Monteure und Teamleiter den Lotse-Chat nutzen – Plätze müssen nicht vergeben werden (Kontingent: {perSeat} Chats je Person und Monat).",
|
||||||
|
"noSeatsBought": "Noch keine Chat-Plätze gebucht. Plätze bucht der Anbieter für Ihren Betrieb.",
|
||||||
|
"overbooked": "Mehr Plätze vergeben als gebucht ({purchased}). Nur die zuerst vergebenen Plätze gelten – bitte Plätze entziehen.",
|
||||||
|
"noFieldUsers": "Keine aktiven Nutzer mit Feldrolle (Monteur, Teamleiter).",
|
||||||
|
"seatAssigned": "Platz vergeben",
|
||||||
|
"seatOverbooked": "Über der Platzanzahl",
|
||||||
|
"seatNone": "Kein Platz",
|
||||||
|
"assign": "Platz vergeben",
|
||||||
|
"revoke": "Platz entziehen",
|
||||||
|
"assignLabel": "Chat-Platz an {name} vergeben",
|
||||||
|
"revokeLabel": "Chat-Platz von {name} entziehen",
|
||||||
|
"saved": "Chat-Platz gespeichert.",
|
||||||
|
"usageTitle": "Verbrauch {month}",
|
||||||
|
"usageText": "{used} von {quota} Chats",
|
||||||
|
"quotaRule": "Kontingent: {perSeat} Chats je vergebenem Platz und Monat, gemeinsam für den Betrieb (Basis: {basis}). Ein Chat ist eine Nachricht an den Lotsen.",
|
||||||
|
"warn80": "{percent} % des Monatskontingents sind genutzt.",
|
||||||
|
"exhausted": "Das Monatskontingent ist ausgeschöpft. Weitere Chats werden als Mehrverbrauch in Paketen à {pack} berechnet.",
|
||||||
|
"hardLimitReached": "Das Monatskontingent ist ausgeschöpft. Der Lotse-Chat ist bis zum Monatsende gesperrt (hartes Limit).",
|
||||||
|
"overage": "Mehrverbrauch: {overage} Chats – entspricht {packs, plural, one {# Paket} other {# Paketen}} à {pack} Chats.",
|
||||||
|
"perUserCaption": "Chats je Nutzer im laufenden Monat",
|
||||||
|
"perUserName": "Nutzer",
|
||||||
|
"perUserChats": "Chats",
|
||||||
|
"errors": {
|
||||||
|
"no_seats_left": "Alle gebuchten Plätze sind vergeben. Bitte zuerst einen Platz entziehen oder weitere Plätze beim Anbieter buchen.",
|
||||||
|
"not_eligible": "Diese Person kann keinen Chat-Platz erhalten (keine aktive Feldrolle).",
|
||||||
|
"not_in_plan": "Der Lotse-Chat ist im Paket Profi enthalten.",
|
||||||
|
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
|
||||||
|
"forbidden": "Dafür fehlt die Berechtigung.",
|
||||||
|
"trial_expired": "Testphase abgelaufen – nur Lesezugriff.",
|
||||||
|
"failed": "Speichern nicht möglich."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"title": "Paket & Lotse-Chat",
|
||||||
|
"tier": "Paket",
|
||||||
|
"trialProfi": "Testphase = Profi",
|
||||||
|
"seats": "Chat-Plätze",
|
||||||
|
"seatsValue": "{assigned} vergeben / {purchased} gebucht",
|
||||||
|
"hardLimit": "Hartes Limit",
|
||||||
|
"hardLimitOn": "an – Sperre beim Kontingent",
|
||||||
|
"hardLimitOff": "aus – Mehrverbrauch wird gezählt",
|
||||||
|
"rule": "{perSeat} Chats je vergebenem Platz und Monat (Zeitzone des Mandanten); Mehrverbrauch in Paketen à {pack}.",
|
||||||
|
"currentMonth": "Laufender Monat · {month}",
|
||||||
|
"previousMonth": "Vormonat · {month}",
|
||||||
|
"chats": "{used} von {quota} Chats",
|
||||||
|
"overage": "Mehrverbrauch {overage} Chats → {packs, plural, one {# Paket} other {# Pakete}} à {pack}",
|
||||||
|
"noOverage": "Kein Mehrverbrauch",
|
||||||
|
"edit": "Paket ändern …",
|
||||||
|
"editTitle": "Paket & Chat-Plätze ändern",
|
||||||
|
"editText": "Die Änderung wirkt sofort für alle Nutzer des Mandanten und wird im Plattform-Audit protokolliert.",
|
||||||
|
"tierHint": {
|
||||||
|
"BASIS": "Ohne KI und Planung: kein Lotse, keine Plantafel/Live-Lage, keine Abrechnungsübersicht, kein Notdienst, kein Auftragsimport.",
|
||||||
|
"PROFI": "Alle Funktionen inkl. Planung, Abrechnungsübersicht, Notdienst, Import und Lotse."
|
||||||
|
},
|
||||||
|
"downgradeHint": "Wechsel zu Basis: Daten bleiben erhalten, nur der Zugriff endet. Zurück zu Profi ist alles wieder da.",
|
||||||
|
"seatsInput": "Gebuchte Chat-Plätze",
|
||||||
|
"seatsHint": "Der Mandanten-Admin vergibt die Plätze an Nutzer mit Feldrolle.",
|
||||||
|
"hardLimitInput": "Hartes Limit",
|
||||||
|
"hardLimitHint": "Nach Ausschöpfen des Monatskontingents wird der Chat gesperrt statt Mehrverbrauch zu zählen.",
|
||||||
|
"confirm": "Ich habe die Auswirkungen geprüft und bestätige die Änderung.",
|
||||||
|
"submit": "Speichern",
|
||||||
|
"submitting": "Speichern …",
|
||||||
|
"close": "Schließen",
|
||||||
|
"saved": "Paket gespeichert.",
|
||||||
|
"errors": {
|
||||||
|
"confirm_required": "Bitte die Änderung bestätigen.",
|
||||||
|
"invalid_input": "Bitte Eingaben prüfen (Plätze 0–1000).",
|
||||||
|
"tenant_archived": "Der Mandant ist archiviert.",
|
||||||
|
"platform_admin_required": "Nur Voll-Administratoren dürfen das Paket ändern.",
|
||||||
|
"failed": "Speichern nicht möglich."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,11 +317,15 @@
|
|||||||
"transition_forbidden": "You lack the permission.",
|
"transition_forbidden": "You lack the permission.",
|
||||||
"not_editable": "The report has been submitted and can no longer be changed.",
|
"not_editable": "The report has been submitted and can no longer be changed.",
|
||||||
"reason_required": "The reason is missing.",
|
"reason_required": "The reason is missing.",
|
||||||
"use_billing_overview": "Only possible via the billing overview."
|
"use_billing_overview": "Only possible via the billing overview.",
|
||||||
|
"not_in_plan": "The Lotse is included in the Profi package and not activated for this company.",
|
||||||
|
"no_seat": "The Lotse chat is not activated for you yet – please ask the office.",
|
||||||
|
"quota_exhausted": "The company's chat quota for this month is used up."
|
||||||
},
|
},
|
||||||
"unavailable": {
|
"unavailable": {
|
||||||
"title": "Lotse chat not available",
|
"title": "Lotse chat not available",
|
||||||
"back": "Back to overview"
|
"back": "Back to overview"
|
||||||
}
|
},
|
||||||
|
"quotaExhausted": "The company's chat quota for this month is used up. You can write again from next month – the office can help with questions."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"title": "Craftvia — field service app for trades and installation",
|
||||||
|
"description": "Jobs, scheduling, time tracking, reports and billing in one system. Mobile, offline-capable and tenant-safe — for trade and installation businesses."
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"label": "Main navigation",
|
||||||
|
"features": "Features",
|
||||||
|
"planning": "Scheduling",
|
||||||
|
"mobile": "On site",
|
||||||
|
"security": "Security",
|
||||||
|
"pricing": "Free trial",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"login": "Sign in",
|
||||||
|
"cta": "Start free trial",
|
||||||
|
"menu": "Menu"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": "For trade and installation businesses",
|
||||||
|
"title": "Craftwork. Digitally on course.",
|
||||||
|
"subtitle": "Craftvia brings jobs, scheduling, times, material and reports together — at the desk and on site. Even without a connection.",
|
||||||
|
"ctaPrimary": "Try it free for 14 days",
|
||||||
|
"ctaSecondary": "See the features",
|
||||||
|
"bullet1": "Set up in minutes, nothing to install",
|
||||||
|
"bullet2": "Works offline — entries are never lost",
|
||||||
|
"bullet3": "No payment details for the trial"
|
||||||
|
},
|
||||||
|
"pillars": {
|
||||||
|
"title": "What holds Craftvia together",
|
||||||
|
"one": {
|
||||||
|
"title": "One job, one truth",
|
||||||
|
"text": "Appointment, crew, times, material, photos and report belong to the same job — no notes, no double entry."
|
||||||
|
},
|
||||||
|
"two": {
|
||||||
|
"title": "Office and site in sync",
|
||||||
|
"text": "The office plans, the technician works. Both see the same state, without a phone call."
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"title": "Finished, not reworked",
|
||||||
|
"text": "Mandatory fields, photos and the signature are checked on site — the job is done when the technician drives off."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "Everything a job needs",
|
||||||
|
"sub": "Modules that work together. Enable what you need.",
|
||||||
|
"orders": {
|
||||||
|
"title": "Jobs",
|
||||||
|
"text": "From the call to sign-off: create, schedule, assign, see the status. Custom job types with checklists and required photos."
|
||||||
|
},
|
||||||
|
"board": {
|
||||||
|
"title": "Scheduling board",
|
||||||
|
"text": "Drag jobs onto a crew and a day. Utilisation, overbooking and overlaps are visible at once."
|
||||||
|
},
|
||||||
|
"live": {
|
||||||
|
"title": "Live situation",
|
||||||
|
"text": "Who is en route, working or on a break — at the site of the running job. No GPS tracking of people."
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"title": "Time tracking",
|
||||||
|
"text": "Start, pause, stop with one tap. Travel and work time separated; later corrections need a reason and approval."
|
||||||
|
},
|
||||||
|
"offline": {
|
||||||
|
"title": "Mobile and offline",
|
||||||
|
"text": "In a basement or a dead spot: entries are stored on the device and sent automatically."
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"title": "Report and signature",
|
||||||
|
"text": "Fill in the service report on the device, the customer signs on screen, the PDF goes out."
|
||||||
|
},
|
||||||
|
"material": {
|
||||||
|
"title": "Material",
|
||||||
|
"text": "Record planned and actually used material, deviations with a reason — clean for costing."
|
||||||
|
},
|
||||||
|
"billing": {
|
||||||
|
"title": "Billing overview",
|
||||||
|
"text": "Completed jobs and reached milestones collect in one place: times, material and trips as the basis for the invoice."
|
||||||
|
},
|
||||||
|
"emergency": {
|
||||||
|
"title": "Call-outs",
|
||||||
|
"text": "Create an emergency job in minutes, add the details later, review in the office."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"title": "Lotse — the assistant",
|
||||||
|
"text": "Technicians write or speak like in a chat. Lotse maps it to the job and asks what is missing. Nothing is booked before they confirm."
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"title": "Documents",
|
||||||
|
"text": "Plans, photos and datasheets on the job and the site — available on the road."
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"title": "Job import",
|
||||||
|
"text": "Read jobs from PDF: data is proposed and checked before it is accepted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"planning": {
|
||||||
|
"eyebrow": "Scheduling",
|
||||||
|
"title": "See the week before it goes wrong",
|
||||||
|
"text": "The board shows every crew with its daily capacity. If a day gets too full or two appointments overlap, it is written on the card — not in the customer's phone call.",
|
||||||
|
"point1": "Utilisation per crew and day in percent",
|
||||||
|
"point2": "Warnings for overbooking, overlap and incomplete crews",
|
||||||
|
"point3": "A hint when a job runs longer than planned — and the next one is at risk",
|
||||||
|
"point4": "Suggestions for nearby jobs when a crew finishes early"
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"eyebrow": "On site",
|
||||||
|
"title": "Four taps and the job is documented",
|
||||||
|
"text": "The mobile view is built for gloves and bright light: large targets, clear steps, no hunting through menus.",
|
||||||
|
"step1": {
|
||||||
|
"title": "Start the drive",
|
||||||
|
"text": "Travel time runs automatically."
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Work on site",
|
||||||
|
"text": "Tick the checklist, take photos, record material."
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"title": "Fill in the report",
|
||||||
|
"text": "Dictate or type; mandatory fields are checked."
|
||||||
|
},
|
||||||
|
"step4": {
|
||||||
|
"title": "Collect the signature",
|
||||||
|
"text": "The customer signs on screen, the PDF goes out."
|
||||||
|
},
|
||||||
|
"offlineNote": "No connection? Everything keeps working and is transferred later."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"eyebrow": "Lotse",
|
||||||
|
"title": "Say what happened — instead of hunting for fields",
|
||||||
|
"text": "Lotse understands whole sentences, maps them to the job and asks for what is missing. It shows a card with what it would book. Nothing is written before you confirm.",
|
||||||
|
"chatUser": "Job done, installed the tank, two hours, used one filter",
|
||||||
|
"chatAssistant": "I'm mapping this to job A-00042, Lindenhof apartments. Two questions: was travel included, and should I mark the job technically complete?",
|
||||||
|
"cardTitle": "Proposal",
|
||||||
|
"cardLine1": "Book 2:00 h of work time",
|
||||||
|
"cardLine2": "Record 1 × filter as material",
|
||||||
|
"cardLine3": "Mark the job technically complete",
|
||||||
|
"cardConfirm": "Confirm",
|
||||||
|
"cardDiscard": "Discard",
|
||||||
|
"note": "Permissions, mandatory fields and approvals still apply — the assistant bypasses nothing."
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"title": "Ready in three steps",
|
||||||
|
"one": {
|
||||||
|
"title": "Create the account",
|
||||||
|
"text": "Enter your company, pick the trial period, confirm your e-mail. With sample data if you like."
|
||||||
|
},
|
||||||
|
"two": {
|
||||||
|
"title": "Add crew and customers",
|
||||||
|
"text": "Invite technicians, form crews, add or import customers and sites."
|
||||||
|
},
|
||||||
|
"three": {
|
||||||
|
"title": "Run the first job",
|
||||||
|
"text": "Schedule it, the technician handles it on the phone, the report comes back to the office."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"industries": {
|
||||||
|
"title": "For businesses that drive out",
|
||||||
|
"sub": "Built for installation and service — whatever the trade.",
|
||||||
|
"shk": "Plumbing, heating, air conditioning",
|
||||||
|
"electric": "Electrical engineering",
|
||||||
|
"cooling": "Refrigeration and ventilation",
|
||||||
|
"lifts": "Lifts, gates, drives",
|
||||||
|
"windows": "Windows, doors, façades",
|
||||||
|
"service": "Industrial and plant service"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"eyebrow": "Security and privacy",
|
||||||
|
"title": "Your data stays yours",
|
||||||
|
"text": "Craftvia is built multi-tenant: every business sees only its own data — enforced down in the database, not just in the interface.",
|
||||||
|
"item1": {
|
||||||
|
"title": "Strict tenant separation",
|
||||||
|
"text": "Access rules in the database itself keep data inside the business."
|
||||||
|
},
|
||||||
|
"item2": {
|
||||||
|
"title": "Roles and permissions",
|
||||||
|
"text": "Admin, office, crew lead and technician see exactly what they need. Checked on the server."
|
||||||
|
},
|
||||||
|
"item3": {
|
||||||
|
"title": "Two-factor and passkeys",
|
||||||
|
"text": "Sign in with a second factor or a passkey, enforceable for the whole business."
|
||||||
|
},
|
||||||
|
"item4": {
|
||||||
|
"title": "Complete audit trail",
|
||||||
|
"text": "Every write is recorded — who, when, what."
|
||||||
|
},
|
||||||
|
"item5": {
|
||||||
|
"title": "Export any time",
|
||||||
|
"text": "Download your data as a file — including at the end of the trial."
|
||||||
|
},
|
||||||
|
"item6": {
|
||||||
|
"title": "Data minimalism",
|
||||||
|
"text": "Location means the site of the running job, never tracking of a person."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trial": {
|
||||||
|
"eyebrow": "Free trial",
|
||||||
|
"title": "Try it for 14 days, decide in peace",
|
||||||
|
"text": "You set the trial period yourself and start empty or with sample data. After it ends your data stays readable and exportable — nothing disappears overnight.",
|
||||||
|
"point1": "Full functionality, no payment details",
|
||||||
|
"point2": "Reminder before it ends, extension any time",
|
||||||
|
"point3": "Move to the full version without migrating data",
|
||||||
|
"cta": "Start the trial",
|
||||||
|
"secondary": "Request a quote",
|
||||||
|
"note": "Pricing for the full version on request — we come back with a proposal that fits your size."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Frequently asked questions",
|
||||||
|
"q1": {
|
||||||
|
"q": "Do my technicians need training?",
|
||||||
|
"a": "No. The mobile view guides them through the job: drive, work, document, collect the signature. Most teams start after a short briefing."
|
||||||
|
},
|
||||||
|
"q2": {
|
||||||
|
"q": "Does the app work without internet?",
|
||||||
|
"a": "Yes. Entries, photos and times are stored on the device and transferred automatically once there is a connection again."
|
||||||
|
},
|
||||||
|
"q3": {
|
||||||
|
"q": "Do I have to install anything?",
|
||||||
|
"a": "No. Craftvia runs in the browser and can be added to the home screen like an app."
|
||||||
|
},
|
||||||
|
"q4": {
|
||||||
|
"q": "What happens after the trial?",
|
||||||
|
"a": "Your data stays readable and exportable. You can extend or move to the full version — without migrating data."
|
||||||
|
},
|
||||||
|
"q5": {
|
||||||
|
"q": "Can I bring existing customers and jobs?",
|
||||||
|
"a": "Yes. Customers and sites can be imported, jobs additionally read from PDF and checked before they are accepted."
|
||||||
|
},
|
||||||
|
"q6": {
|
||||||
|
"q": "Are my technicians tracked by GPS?",
|
||||||
|
"a": "No. What is shown is the site of the running job, not the position of the device or the person."
|
||||||
|
},
|
||||||
|
"q7": {
|
||||||
|
"q": "Does Craftvia replace my accounting?",
|
||||||
|
"a": "No. Craftvia prepares billing — times, material, trips and milestones. Your accounting writes the invoice."
|
||||||
|
},
|
||||||
|
"q8": {
|
||||||
|
"q": "How many users are possible?",
|
||||||
|
"a": "From a one-person business to several crews. You define teams, roles and capacity yourself."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"closing": {
|
||||||
|
"title": "Ready for the first job?",
|
||||||
|
"text": "Set up your trial access and plan the first week today.",
|
||||||
|
"cta": "Start free trial",
|
||||||
|
"login": "I already have an account"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"tagline": "The field service app for trade and installation businesses.",
|
||||||
|
"product": "Product",
|
||||||
|
"company": "Legal",
|
||||||
|
"features": "Features",
|
||||||
|
"trial": "Free trial",
|
||||||
|
"login": "Sign in",
|
||||||
|
"terms": "Terms of use",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"imprint": "Imprint",
|
||||||
|
"rights": "All rights reserved."
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"planung": {
|
||||||
|
"title": "Scheduling board",
|
||||||
|
"lead": "The week at a glance: distribute jobs across crews, see utilisation, spot conflicts before the customer calls.",
|
||||||
|
"p1": "Drag jobs onto a crew and a day — or schedule them entirely without a mouse",
|
||||||
|
"p2": "Utilisation per crew and day in percent, measured against the crew day",
|
||||||
|
"p3": "Warnings for overbooking, overlap and incomplete crews",
|
||||||
|
"p4": "Unplanned jobs in their own column, with suggestions attached to the card",
|
||||||
|
"p5": "The live view shows who is en route, working or on a break",
|
||||||
|
"shot1": "Scheduling board across five days with utilisation and marked conflicts",
|
||||||
|
"shot2": "Live situation as tiles: crews with status, job and place",
|
||||||
|
"c1title": "Crews, not individuals",
|
||||||
|
"c1text": "A crew travels together. Capacity is the crew day, not the sum of person-hours.",
|
||||||
|
"c2title": "Finished early, used well",
|
||||||
|
"c2text": "When a job ends early, Craftvia suggests nearby work — a person decides.",
|
||||||
|
"c3title": "Delays made visible",
|
||||||
|
"c3text": "If a job overruns, the board marks the following one as at risk."
|
||||||
|
},
|
||||||
|
"baustelle": {
|
||||||
|
"title": "On site",
|
||||||
|
"lead": "The mobile view guides through the job: drive, work, document, collect the signature — even without a connection.",
|
||||||
|
"p1": "Large targets and clear steps, usable with work gloves",
|
||||||
|
"p2": "Checklist, required photos and material right on the job",
|
||||||
|
"p3": "Works offline: entries are stored and sent automatically",
|
||||||
|
"p4": "The customer signs on screen, the PDF goes out afterwards",
|
||||||
|
"p5": "Create call-out jobs in minutes and complete them later",
|
||||||
|
"shot1": "The technician's day view with the running timer",
|
||||||
|
"shot2": "A job on the phone: status, checklist, material and photos in one place",
|
||||||
|
"c1title": "No more paper",
|
||||||
|
"c1text": "What is captured on site is immediately available in the office.",
|
||||||
|
"c2title": "Mandatory fields before sign-off",
|
||||||
|
"c2text": "Missing photos or signatures surface on site, not days later.",
|
||||||
|
"c3title": "Sync without thinking",
|
||||||
|
"c3text": "The app transfers on its own as soon as there is a connection."
|
||||||
|
},
|
||||||
|
"zeiterfassung": {
|
||||||
|
"title": "Time tracking",
|
||||||
|
"lead": "Start, pause, stop with one tap. Travel and work time separated, corrections traceable.",
|
||||||
|
"p1": "The running timer is visible on every page, with a one-tap \"finish for today\"",
|
||||||
|
"p2": "Automatic switch when work starts on another job",
|
||||||
|
"p3": "Add times for the last few days — with a reason",
|
||||||
|
"p4": "Approval by the crew lead or the office; pending entries never count for billing",
|
||||||
|
"p5": "The office can record times for staff, recorded in the audit trail",
|
||||||
|
"shot1": "My times: the day with travel and work time",
|
||||||
|
"shot2": "Approval in the office: review and confirm pending entries",
|
||||||
|
"c1title": "Honest hours",
|
||||||
|
"c1text": "Time is recorded while working, not from memory in the evening.",
|
||||||
|
"c2title": "Corrections stay visible",
|
||||||
|
"c2text": "Every correction carries a reason and an approval.",
|
||||||
|
"c3title": "Travel kept separate",
|
||||||
|
"c3text": "Trips count on their own and appear as a position in billing."
|
||||||
|
},
|
||||||
|
"berichte": {
|
||||||
|
"title": "Reports and signature",
|
||||||
|
"lead": "The service report is created on site — checked, signed and sent to the customer as a PDF.",
|
||||||
|
"p1": "The report sits on the job, with a template per job type",
|
||||||
|
"p2": "Mandatory fields and photos are checked before submission",
|
||||||
|
"p3": "Signature on screen, or \"customer not present\" with a reason",
|
||||||
|
"p4": "Approval in the office, then automatic dispatch as a PDF",
|
||||||
|
"p5": "Every version is kept; older ones are marked as superseded",
|
||||||
|
"shot1": "Reports in the office: from submitted to approved",
|
||||||
|
"shot2": "Job detail with times, material, photos and the report",
|
||||||
|
"c1title": "One record, not a collection",
|
||||||
|
"c1text": "Report, photos, times and material belong to the same job.",
|
||||||
|
"c2title": "Traceable",
|
||||||
|
"c2text": "Who submitted, checked and approved is in the audit trail.",
|
||||||
|
"c3title": "Straight to the customer",
|
||||||
|
"c3text": "After approval the PDF is e-mailed automatically."
|
||||||
|
},
|
||||||
|
"abrechnung": {
|
||||||
|
"title": "Billing overview",
|
||||||
|
"lead": "Completed jobs and reached milestones collect in one place — with times, material and trips for accounting.",
|
||||||
|
"p1": "Completed jobs, milestones and approved daily reports in one list",
|
||||||
|
"p2": "Only approved times count; pending entries are shown separately",
|
||||||
|
"p3": "Trips from time tracking, counted per crew and day",
|
||||||
|
"p4": "Billing sheet as a PDF, \"billed\" with an optional invoice number",
|
||||||
|
"p5": "Voiding needs a reason; billed positions stay clearly assigned",
|
||||||
|
"shot1": "Billing overview with open and billed records",
|
||||||
|
"c1title": "No double billing",
|
||||||
|
"c1text": "Every hour and every part belongs to exactly one billed section.",
|
||||||
|
"c2title": "Accounting stays accounting",
|
||||||
|
"c2text": "Craftvia provides the basis — prices, taxes and invoices stay in your system.",
|
||||||
|
"c3title": "Milestones instead of one final bill",
|
||||||
|
"c3text": "On long projects you bill what has been reached."
|
||||||
|
},
|
||||||
|
"lotse": {
|
||||||
|
"title": "Lotse — the assistant",
|
||||||
|
"lead": "Say what happened: Lotse maps it, asks what is missing and proposes bookings. Nothing is stored before you confirm.",
|
||||||
|
"p1": "Write or speak like in a chat, even mid-job",
|
||||||
|
"p2": "Maps the job itself — from context, the running timer or the day's plan",
|
||||||
|
"p3": "Asks for missing details instead of guessing",
|
||||||
|
"p4": "Shows action cards for status, times, material and notes",
|
||||||
|
"p5": "Permissions, mandatory fields and approvals still apply",
|
||||||
|
"shot1": "Lotse chat on the phone: input by text or voice",
|
||||||
|
"c1title": "Confirm instead of typing",
|
||||||
|
"c1text": "The technician checks a card and taps once — instead of filling four forms.",
|
||||||
|
"c2title": "Nothing happens quietly",
|
||||||
|
"c2text": "Without confirmation nothing is booked. Every booking lands in the audit trail.",
|
||||||
|
"c3title": "A reference too",
|
||||||
|
"c3text": "Questions like \"where is the access code?\" are answered from the job."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Features at a glance",
|
||||||
|
"lead": "Craftvia covers the whole job — from the incoming request to the basis for the invoice. Every module can be enabled on its own.",
|
||||||
|
"more": "Learn more"
|
||||||
|
},
|
||||||
|
"securityPage": {
|
||||||
|
"title": "Security and privacy",
|
||||||
|
"lead": "Craftvia is built multi-tenant. Every business sees only its own data — enforced down in the database, not just in the interface.",
|
||||||
|
"shot1": "Audit trail: every write with time, person and change",
|
||||||
|
"shot2": "Teams and roles: assignment decides who sees which jobs",
|
||||||
|
"detailTitle": "What that means day to day",
|
||||||
|
"d1title": "Field roles see only their jobs",
|
||||||
|
"d1text": "Technicians see their crew's jobs, not the company's customer list.",
|
||||||
|
"d2title": "Permissions are enforced on the server",
|
||||||
|
"d2text": "Every change is checked server-side. Hidden buttons are convenience, not security.",
|
||||||
|
"d3title": "Traceable, not trusting",
|
||||||
|
"d3text": "The trail records who changed what and when — useful for accounting too."
|
||||||
|
},
|
||||||
|
"pricingPage": {
|
||||||
|
"title": "Trial and pricing",
|
||||||
|
"lead": "Start with a trial and decide afterwards. The price of the full version depends on the size of your business.",
|
||||||
|
"trialTitle": "Trial",
|
||||||
|
"trialText": "Full functionality, period of your choice, no payment details. After it ends your data stays readable and exportable.",
|
||||||
|
"t1": "Up to 30 days, end date of your choice",
|
||||||
|
"t2": "Start with sample data or empty",
|
||||||
|
"t3": "Reminder before it ends, extension any time",
|
||||||
|
"fullTitle": "Full version",
|
||||||
|
"fullText": "Switch over without migrating data: the trial tenant is converted, everything is kept.",
|
||||||
|
"f1": "All modules, unlimited jobs",
|
||||||
|
"f2": "Priced by users and crews",
|
||||||
|
"f3": "A quote after a short conversation",
|
||||||
|
"note": "We name concrete prices once they are settled — deliberately no number here that would not hold later."
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Try Craftvia in your own business",
|
||||||
|
"text": "Set up trial access, load sample data and plan the first week — in minutes.",
|
||||||
|
"primary": "Start free trial",
|
||||||
|
"secondary": "See all features"
|
||||||
|
},
|
||||||
|
"shotFrame": {
|
||||||
|
"label": "Screenshot from Craftvia"
|
||||||
|
},
|
||||||
|
"imprint": {
|
||||||
|
"title": "Imprint",
|
||||||
|
"intro": "Information pursuant to § 5 of the German Digital Services Act (DDG).",
|
||||||
|
"provider": "Provider",
|
||||||
|
"represented": "Represented by",
|
||||||
|
"contact": "Contact",
|
||||||
|
"phone": "Phone",
|
||||||
|
"email": "E-mail",
|
||||||
|
"register": "Commercial register",
|
||||||
|
"registerCourt": "Register court",
|
||||||
|
"registerNumber": "Register number",
|
||||||
|
"vat": "VAT ID",
|
||||||
|
"vatText": "VAT identification number pursuant to § 27 a of the German VAT Act:",
|
||||||
|
"editorial": "Editorially responsible",
|
||||||
|
"dispute": "Consumer dispute resolution",
|
||||||
|
"disputeText": "We are neither willing nor obliged to take part in dispute resolution proceedings before a consumer arbitration board."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,9 @@
|
|||||||
"internal": "Technical error – will retry automatically.",
|
"internal": "Technical error – will retry automatically.",
|
||||||
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
|
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
|
||||||
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
|
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
|
||||||
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch."
|
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.",
|
||||||
|
"notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.",
|
||||||
|
"moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office."
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"title": "Offline view",
|
"title": "Offline view",
|
||||||
|
|||||||
@@ -89,7 +89,8 @@
|
|||||||
"noTeam": "no team",
|
"noTeam": "no team",
|
||||||
"noDate": "no date",
|
"noDate": "no date",
|
||||||
"noLocation": "no location",
|
"noLocation": "no location",
|
||||||
"suggestions": "Suggestions"
|
"suggestions": "Suggestions",
|
||||||
|
"suggestionsFor": "Suggestions for {number}"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Schedule order",
|
"title": "Schedule order",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"tier": {
|
||||||
|
"BASIS": "Basic",
|
||||||
|
"PROFI": "Profi"
|
||||||
|
},
|
||||||
|
"colTier": "Package",
|
||||||
|
"notInTier": "Profi package only",
|
||||||
|
"profiHint": {
|
||||||
|
"title": "Included in the Profi package",
|
||||||
|
"text": "This function is part of the Profi package. Your company uses the Basic package – please contact your Craftvia representative to upgrade."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Package & Lotse chat",
|
||||||
|
"sub": "Package of the company, chat seats for technicians and usage in the current month",
|
||||||
|
"package": "Package",
|
||||||
|
"trial": "Trial (all functions)",
|
||||||
|
"packageReadOnly": "The provider sets the package and the number of chat seats. Here you assign the purchased seats to your technicians and team leads.",
|
||||||
|
"basisHint": "Lotse and the Lotse chat are included in the Profi package. Assigned seats and chat histories are kept and available again after switching to Profi.",
|
||||||
|
"seatsTitle": "Chat seats",
|
||||||
|
"seatsCount": "{assigned} of {purchased} seats assigned",
|
||||||
|
"trialSeats": "During the trial all technicians and team leads may use the Lotse chat – no seats need to be assigned (quota: {perSeat} chats per person and month).",
|
||||||
|
"noSeatsBought": "No chat seats purchased yet. The provider books seats for your company.",
|
||||||
|
"overbooked": "More seats assigned than purchased ({purchased}). Only the seats assigned first are valid – please revoke seats.",
|
||||||
|
"noFieldUsers": "No active users with a field role (technician, team lead).",
|
||||||
|
"seatAssigned": "Seat assigned",
|
||||||
|
"seatOverbooked": "Above the seat number",
|
||||||
|
"seatNone": "No seat",
|
||||||
|
"assign": "Assign seat",
|
||||||
|
"revoke": "Revoke seat",
|
||||||
|
"assignLabel": "Assign chat seat to {name}",
|
||||||
|
"revokeLabel": "Revoke chat seat from {name}",
|
||||||
|
"saved": "Chat seat saved.",
|
||||||
|
"usageTitle": "Usage {month}",
|
||||||
|
"usageText": "{used} of {quota} chats",
|
||||||
|
"quotaRule": "Quota: {perSeat} chats per assigned seat and month, shared by the company (basis: {basis}). One chat is one message to the Lotse.",
|
||||||
|
"warn80": "{percent} % of the monthly quota are used.",
|
||||||
|
"exhausted": "The monthly quota is used up. Further chats are billed as overage in packs of {pack}.",
|
||||||
|
"hardLimitReached": "The monthly quota is used up. The Lotse chat is blocked until the end of the month (hard limit).",
|
||||||
|
"overage": "Overage: {overage} chats – equals {packs, plural, one {# pack} other {# packs}} of {pack} chats.",
|
||||||
|
"perUserCaption": "Chats per user in the current month",
|
||||||
|
"perUserName": "User",
|
||||||
|
"perUserChats": "Chats",
|
||||||
|
"errors": {
|
||||||
|
"no_seats_left": "All purchased seats are assigned. Please revoke a seat first or book more seats with the provider.",
|
||||||
|
"not_eligible": "This person cannot get a chat seat (no active field role).",
|
||||||
|
"not_in_plan": "The Lotse chat is included in the Profi package.",
|
||||||
|
"disabled": "Lotse is switched off for this company.",
|
||||||
|
"forbidden": "You are not allowed to do this.",
|
||||||
|
"trial_expired": "Trial expired – read-only access.",
|
||||||
|
"failed": "Could not save."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"title": "Package & Lotse chat",
|
||||||
|
"tier": "Package",
|
||||||
|
"trialProfi": "Trial = Profi",
|
||||||
|
"seats": "Chat seats",
|
||||||
|
"seatsValue": "{assigned} assigned / {purchased} purchased",
|
||||||
|
"hardLimit": "Hard limit",
|
||||||
|
"hardLimitOn": "on – blocked at the quota",
|
||||||
|
"hardLimitOff": "off – overage is counted",
|
||||||
|
"rule": "{perSeat} chats per assigned seat and month (tenant timezone); overage in packs of {pack}.",
|
||||||
|
"currentMonth": "Current month · {month}",
|
||||||
|
"previousMonth": "Previous month · {month}",
|
||||||
|
"chats": "{used} of {quota} chats",
|
||||||
|
"overage": "Overage {overage} chats → {packs, plural, one {# pack} other {# packs}} of {pack}",
|
||||||
|
"noOverage": "No overage",
|
||||||
|
"edit": "Change package …",
|
||||||
|
"editTitle": "Change package & chat seats",
|
||||||
|
"editText": "The change applies immediately to all users of the tenant and is logged in the platform audit.",
|
||||||
|
"tierHint": {
|
||||||
|
"BASIS": "Without AI and planning: no Lotse, no planning board/live situation, no billing overview, no emergency service, no order import.",
|
||||||
|
"PROFI": "All functions incl. planning, billing overview, emergency service, import and Lotse."
|
||||||
|
},
|
||||||
|
"downgradeHint": "Switching to Basic keeps all data, only access ends. Back on Profi everything is available again.",
|
||||||
|
"seatsInput": "Purchased chat seats",
|
||||||
|
"seatsHint": "The tenant admin assigns the seats to users with a field role.",
|
||||||
|
"hardLimitInput": "Hard limit",
|
||||||
|
"hardLimitHint": "When the monthly quota is used up the chat is blocked instead of counting overage.",
|
||||||
|
"confirm": "I checked the impact and confirm the change.",
|
||||||
|
"submit": "Save",
|
||||||
|
"submitting": "Saving …",
|
||||||
|
"close": "Close",
|
||||||
|
"saved": "Package saved.",
|
||||||
|
"errors": {
|
||||||
|
"confirm_required": "Please confirm the change.",
|
||||||
|
"invalid_input": "Please check the input (seats 0–1000).",
|
||||||
|
"tenant_archived": "The tenant is archived.",
|
||||||
|
"platform_admin_required": "Only full administrators may change the package.",
|
||||||
|
"failed": "Could not save."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze
|
||||||
|
-- Additiv: Stufe + Platzanzahl + hartes Limit am Mandanten (Default PROFI = bestehende Mandanten unverändert),
|
||||||
|
-- Tabelle lotse_chat_seats (Mandanten-RLS), Index für die Monatszählung der Chat-Nachrichten.
|
||||||
|
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TenantTier" AS ENUM ('BASIS', 'PROFI');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "lotse_chat_hard_limit" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "lotse_chat_seats" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "tier" "TenantTier" NOT NULL DEFAULT 'PROFI';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "lotse_chat_seats" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenant_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"assigned_by_id" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "lotse_chat_seats_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "lotse_chat_seats_tenant_id_created_at_idx" ON "lotse_chat_seats"("tenant_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lotse_chat_seats_tenant_id_user_id_key" ON "lotse_chat_seats"("tenant_id", "user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "lotse_messages_tenant_id_role_created_at_idx" ON "lotse_messages"("tenant_id", "role", "created_at");
|
||||||
|
|
||||||
|
|
||||||
|
-- Mandantentrennung (docs/craftvia/MIGRATIONS.md)
|
||||||
|
SELECT enable_tenant_rls('lotse_chat_seats');
|
||||||
@@ -61,6 +61,13 @@ model Tenant {
|
|||||||
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
|
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
|
||||||
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
|
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
|
||||||
|
|
||||||
|
// L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert)
|
||||||
|
tier TenantTier @default(PROFI)
|
||||||
|
/// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin)
|
||||||
|
lotseChatSeats Int @default(0) @map("lotse_chat_seats")
|
||||||
|
/// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch
|
||||||
|
lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit")
|
||||||
|
|
||||||
users User[]
|
users User[]
|
||||||
roles Role[]
|
roles Role[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
@@ -1505,6 +1512,8 @@ model LotseMessage {
|
|||||||
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([tenantId, conversationId, createdAt])
|
@@index([tenantId, conversationId, createdAt])
|
||||||
|
// L17 Pakete: Monatszählung der Chats (role = user) je Mandant
|
||||||
|
@@index([tenantId, role, createdAt])
|
||||||
@@map("lotse_messages")
|
@@map("lotse_messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1595,3 +1604,23 @@ model TenantExport {
|
|||||||
@@index([tenantId, createdAt])
|
@@index([tenantId, createdAt])
|
||||||
@@map("tenant_exports")
|
@@map("tenant_exports")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze ───────────────────────────────
|
||||||
|
|
||||||
|
enum TenantTier {
|
||||||
|
BASIS
|
||||||
|
PROFI
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vergebener Lotse-Chat-Platz (Mandanten-Admin vergibt bis zur vom Betreiber gesetzten Anzahl).
|
||||||
|
model LotseChatSeat {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
tenantId String @map("tenant_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
assignedById String? @map("assigned_by_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([tenantId, userId])
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@map("lotse_chat_seats")
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 345 KiB |
@@ -61,6 +61,8 @@ const ACTION_MODULE: Record<string, string> = {
|
|||||||
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
|
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
|
||||||
"trial-platform.ts": "EXEMPT",
|
"trial-platform.ts": "EXEMPT",
|
||||||
"trial-tenant.ts": "EXEMPT",
|
"trial-tenant.ts": "EXEMPT",
|
||||||
|
// L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin)
|
||||||
|
"plans-platform.ts": "EXEMPT",
|
||||||
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
|
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
|
||||||
"trial-signup.ts": "PUBLIC",
|
"trial-signup.ts": "PUBLIC",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Erzeugt die Env-Datei für den Coolify-Testserver aus .env.coolify.example — mit frischen
|
||||||
|
# Geheimnissen, die nur auf diesem Rechner entstehen. Die Datei ist per .gitignore (.env*)
|
||||||
|
# vom Repo ausgeschlossen; Inhalt in Coolify unter "Environment Variables" einfügen.
|
||||||
|
#
|
||||||
|
# ./scripts/generate-coolify-env.sh # schreibt .env.coolify.local
|
||||||
|
# APP_DOMAIN=app.craftvia.de MARKETING_HOST=craftvia.de ./scripts/generate-coolify-env.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT="${1:-.env.coolify.local}"
|
||||||
|
APP_DOMAIN="${APP_DOMAIN:-app.craftvia.de}"
|
||||||
|
MARKETING_HOST="${MARKETING_HOST:-craftvia.de}"
|
||||||
|
|
||||||
|
if [ -e "$OUT" ]; then
|
||||||
|
echo "$OUT existiert bereits und wird nicht überschrieben (Geheimnisse wären sonst verloren)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
hex() { openssl rand -hex "$1"; }
|
||||||
|
DB_PASSWORD="$(hex 24)"
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s|CHANGE_ME_db_password|${DB_PASSWORD}|g" \
|
||||||
|
-e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(hex 24)|" \
|
||||||
|
-e "s|^S3_ACCESS_KEY=.*|S3_ACCESS_KEY=GK$(hex 12)|" \
|
||||||
|
-e "s|^S3_SECRET_KEY=.*|S3_SECRET_KEY=$(hex 32)|" \
|
||||||
|
-e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(hex 32)|" \
|
||||||
|
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(hex 32)|" \
|
||||||
|
-e "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 32 | tr -d '\n')|" \
|
||||||
|
-e "s|^PASSWORD_PEPPER=.*|PASSWORD_PEPPER=$(hex 32)|" \
|
||||||
|
-e "s|^AUTH_URL=.*|AUTH_URL=https://${APP_DOMAIN}|" \
|
||||||
|
-e "s|^MARKETING_HOST=.*|MARKETING_HOST=${MARKETING_HOST}|" \
|
||||||
|
.env.coolify.example > "$OUT"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# --- erzeugt von scripts/generate-coolify-env.sh ($(date +%F)) ---"
|
||||||
|
echo "MFA_ENC_KEY=$(hex 32)"
|
||||||
|
echo "BACKUP_ENC_KEY=$(hex 32)"
|
||||||
|
} >> "$OUT"
|
||||||
|
chmod 600 "$OUT"
|
||||||
|
|
||||||
|
echo "Geschrieben: $OUT (nur lokal, Rechte 600)."
|
||||||
|
echo "Noch von Hand ergänzen: SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM, ANTHROPIC_API_KEY, TRIAL_CONTACT_EMAIL."
|
||||||
|
echo "Danach sicher ablegen (Passwortmanager) — PASSWORD_PEPPER und MFA_ENC_KEY sind nicht rotierbar."
|
||||||
@@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
|
||||||
|
await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
|
||||||
|
|
||||||
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
|
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
|
||||||
return { orders, created };
|
return { orders, created };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
|
||||||
|
* existing seats of other users are left alone.
|
||||||
|
*/
|
||||||
|
export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise<void> {
|
||||||
|
await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
|
||||||
|
const db = dbForTenant(tenantId);
|
||||||
|
for (const userId of opts.seatUserIds ?? []) {
|
||||||
|
const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
|
||||||
|
if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Isolation fixture for the second tenant: one customer + one work order. */
|
/** Isolation fixture for the second tenant: one customer + one work order. */
|
||||||
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
|
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
|
||||||
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
|
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
|
||||||
@@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr
|
|||||||
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
|
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
|
||||||
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
|
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
|
||||||
}
|
}
|
||||||
|
// L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
|
||||||
|
await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the owner client used by the services (the seed has its own client). */
|
/** Close the owner client used by the services (the seed has its own client). */
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise<void> {
|
|||||||
await prisma.mailLog.deleteMany(w);
|
await prisma.mailLog.deleteMany(w);
|
||||||
await prisma.authToken.deleteMany(w);
|
await prisma.authToken.deleteMany(w);
|
||||||
await prisma.aiGeneration.deleteMany(w);
|
await prisma.aiGeneration.deleteMany(w);
|
||||||
|
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
|
||||||
await prisma.signature.deleteMany(w);
|
await prisma.signature.deleteMany(w);
|
||||||
await prisma.report.deleteMany(w);
|
await prisma.report.deleteMany(w);
|
||||||
await prisma.photo.deleteMany(w);
|
await prisma.photo.deleteMany(w);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export async function cleanupChatTenants(prefix: string) {
|
|||||||
const ids = tenants.map((t) => t.id);
|
const ids = tenants.map((t) => t.id);
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const w = { where: { tenantId: { in: ids } } };
|
const w = { where: { tenantId: { in: ids } } };
|
||||||
|
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
|
||||||
await prisma.lotseActionProposal.deleteMany(w);
|
await prisma.lotseActionProposal.deleteMany(w);
|
||||||
await prisma.lotseMessage.deleteMany(w);
|
await prisma.lotseMessage.deleteMany(w);
|
||||||
await prisma.lotseConversation.deleteMany(w);
|
await prisma.lotseConversation.deleteMany(w);
|
||||||
@@ -119,6 +120,11 @@ export async function createChatFixture(prefix: string) {
|
|||||||
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } });
|
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } });
|
||||||
const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
|
const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
|
||||||
|
|
||||||
|
// L17 Pakete: the chat needs a seat per user (Profi is the default tier)
|
||||||
|
await prisma.tenant.updateMany({ where: { id: { in: [tA.id, tB.id] } }, data: { lotseChatSeats: 10 } });
|
||||||
|
for (const u of [tech, tech2, outsider, admin]) await prisma.lotseChatSeat.create({ data: { tenantId: tA.id, userId: u.id } });
|
||||||
|
await prisma.lotseChatSeat.create({ data: { tenantId: tB.id, userId: techB.id } });
|
||||||
|
|
||||||
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
|
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
|
||||||
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } });
|
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } });
|
||||||
await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
|
await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Screenshots der laufenden App für die Produktseite (`public/marketing/*.png`).
|
||||||
|
*
|
||||||
|
* Meldet sich OHNE Passworteingabe an: die Session wird wie in `scripts/smoke-auth.ts` gebaut
|
||||||
|
* (finalizeIdentityLogin + next-auth/jwt) und als Cookie in den Browser gesetzt. Gezeigt werden
|
||||||
|
* ausschließlich Demo-Daten des Mandanten `demo`.
|
||||||
|
*
|
||||||
|
* npm run dev # Dev-Server muss laufen
|
||||||
|
* npx tsx scripts/marketing-shots.ts # BASE=http://localhost:3000
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { chromium, type BrowserContext } from "playwright-core";
|
||||||
|
import { encode } from "next-auth/jwt";
|
||||||
|
import { prisma } from "../src/server/db";
|
||||||
|
import { finalizeIdentityLogin } from "../src/server/auth";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE ?? "http://localhost:3000";
|
||||||
|
const COOKIE = "authjs.session-token";
|
||||||
|
const OUT = path.join(process.cwd(), "public", "marketing");
|
||||||
|
|
||||||
|
async function sessionCookie(email: string, tenant: string) {
|
||||||
|
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
|
||||||
|
const user = await finalizeIdentityLogin(identity.id, tenant);
|
||||||
|
if (!user) throw new Error(`keine Mitgliedschaft für ${email}`);
|
||||||
|
const token = {
|
||||||
|
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId,
|
||||||
|
tenantId: user.tenantId, tenantSlug: user.tenantSlug, activeMembershipId: user.activeMembershipId,
|
||||||
|
memberships: user.memberships, roles: user.roles, permissions: user.permissions,
|
||||||
|
isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
|
||||||
|
};
|
||||||
|
return encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 60 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shot = { name: string; path: string; wait?: string; click?: string; fullPage?: boolean };
|
||||||
|
|
||||||
|
async function shoot(ctx: BrowserContext, shots: Shot[], label: string) {
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
for (const s of shots) {
|
||||||
|
await page.goto(`${BASE}${s.path}`, { waitUntil: "domcontentloaded", timeout: 120_000 });
|
||||||
|
if (s.wait) await page.waitForSelector(s.wait, { timeout: 60_000 }).catch(() => undefined);
|
||||||
|
if (s.click) await page.click(s.click, { timeout: 15_000 }).catch(() => undefined);
|
||||||
|
// Dev-Overlay von Next.js gehört nicht ins Produktbild
|
||||||
|
await page.addStyleTag({ content: "nextjs-portal, [data-nextjs-toast], #__next-build-watcher { display: none !important; }" });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.screenshot({ path: path.join(OUT, `${s.name}.png`), fullPage: s.fullPage ?? false });
|
||||||
|
console.log(`✔ ${label}: ${s.name}.png (${s.path})`);
|
||||||
|
}
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(OUT, { recursive: true });
|
||||||
|
const tenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } });
|
||||||
|
const order = await prisma.workOrder.findFirstOrThrow({
|
||||||
|
where: { tenantId: tenant.id, status: "in_progress", deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
||||||
|
const value = await sessionCookie("admin@demo.example", "demo");
|
||||||
|
const techValue = await sessionCookie("monteur@demo.example", "demo");
|
||||||
|
const cookie = { name: COOKIE, value, domain: "localhost", path: "/", httpOnly: true, secure: false, sameSite: "Lax" as const };
|
||||||
|
|
||||||
|
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "de-DE" });
|
||||||
|
await desktop.addCookies([cookie]);
|
||||||
|
await shoot(desktop, [
|
||||||
|
{ name: "plantafel", path: "/planning", wait: "text=Plantafel" },
|
||||||
|
{ name: "live-lage", path: "/planning/live", wait: "text=Live-Lage", click: "text=Kacheln" },
|
||||||
|
{ name: "auftrag", path: `/work-orders/${order.id}`, wait: "text=Status" },
|
||||||
|
{ name: "abrechnung", path: "/billing", wait: "text=Abrechnung" },
|
||||||
|
{ name: "dashboard", path: "/dashboard", wait: "text=Dashboard" },
|
||||||
|
{ name: "auftraege", path: "/work-orders", wait: "text=Aufträge" },
|
||||||
|
{ name: "berichte", path: "/reports", wait: "text=Berichte" },
|
||||||
|
{ name: "zeitfreigabe", path: "/work-orders/time-approvals", wait: "text=Zeiten" },
|
||||||
|
{ name: "audit", path: "/settings/audit", wait: "text=Audit" },
|
||||||
|
{ name: "teams", path: "/teams", wait: "text=Teams" },
|
||||||
|
], "Desktop");
|
||||||
|
await desktop.close();
|
||||||
|
|
||||||
|
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, locale: "de-DE", isMobile: true, hasTouch: true });
|
||||||
|
await mobile.addCookies([{ ...cookie, value: techValue }]);
|
||||||
|
await shoot(mobile, [
|
||||||
|
{ name: "mobil-heute", path: "/m", wait: "text=Heute" },
|
||||||
|
{ name: "mobil-auftrag", path: `/m/orders/${order.id}`, wait: "text=Auftrag" },
|
||||||
|
{ name: "mobil-lotse", path: "/m/lotse", wait: "text=Lotse" },
|
||||||
|
{ name: "mobil-zeiten", path: "/m/time", wait: "text=Meine Zeiten" },
|
||||||
|
], "Mobil");
|
||||||
|
await mobile.close();
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`\nFertig — Dateien in public/marketing/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => prisma.$disconnect())
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
await prisma.$disconnect().catch(() => undefined);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -33,7 +33,18 @@ async function ids() {
|
|||||||
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
|
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
|
||||||
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
|
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
|
||||||
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
|
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
|
||||||
const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } });
|
// "Fremder" Kunde für den Monteur: keiner seiner Aufträge (Team oder Einzelzuweisung) gehört dazu.
|
||||||
|
// Dynamisch statt fest K-D004, weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.
|
||||||
|
const tech = await prisma.user.findFirstOrThrow({ where: { tenantId: demo.id, email: "monteur@demo.example" }, select: { id: true } });
|
||||||
|
const techTeams = (await prisma.teamMember.findMany({ where: { tenantId: demo.id, userId: tech.id }, select: { teamId: true } })).map((m) => m.teamId);
|
||||||
|
const foreignCustomer = await prisma.customer.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
tenantId: demo.id,
|
||||||
|
deletedAt: null,
|
||||||
|
workOrders: { none: { OR: [{ assignedTeamId: { in: techTeams } }, { assignees: { some: { userId: tech.id } } }] } },
|
||||||
|
},
|
||||||
|
orderBy: { customerNumber: "asc" },
|
||||||
|
});
|
||||||
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
|
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
|
||||||
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
|
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
|
||||||
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
|
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
|
||||||
@@ -76,6 +87,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
{ path: "/settings/audit" },
|
{ path: "/settings/audit" },
|
||||||
{ path: "/settings/email" },
|
{ path: "/settings/email" },
|
||||||
{ path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" },
|
{ path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" },
|
||||||
|
// L17 Pakete: Paket nur lesend, Plätze + Verbrauch
|
||||||
|
{ path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] },
|
||||||
|
{ path: "/settings", mustContain: "Freigeschaltete Module" },
|
||||||
{ path: "/settings/lotse/protocol" },
|
{ path: "/settings/lotse/protocol" },
|
||||||
{ path: "/settings/order-types", mustContain: "Montage" },
|
{ path: "/settings/order-types", mustContain: "Montage" },
|
||||||
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
|
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
|
||||||
@@ -154,6 +168,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
// L13 Planung: team lead read-only, own team only
|
// L13 Planung: team lead read-only, own team only
|
||||||
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
|
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
|
||||||
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
|
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
|
||||||
|
// L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis
|
||||||
|
{ path: "/m", mustNotContain: ['href="/m/lotse"'] },
|
||||||
|
{ path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] },
|
||||||
|
...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -189,6 +207,7 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
|
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
|
||||||
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
|
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
|
||||||
{ path: m(x.d07), mustContain: "Lotse fragen" },
|
{ path: m(x.d07), mustContain: "Lotse fragen" },
|
||||||
|
{ path: "/m", mustContain: ['href="/m/lotse"', 'href="/m/emergency"'] }, // L17: Platz vergeben, Profi
|
||||||
...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
|
...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
|
||||||
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
|
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
|
||||||
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
|
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
|
||||||
@@ -224,15 +243,25 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
{ path: `/customers/${x.customer.id}`, expect: [404] },
|
{ path: `/customers/${x.customer.id}`, expect: [404] },
|
||||||
{ path: `/sites/${x.site.id}`, expect: [404] },
|
{ path: `/sites/${x.site.id}`, expect: [404] },
|
||||||
{ path: `/reports/${x.approved.id}`, expect: [404] },
|
{ path: `/reports/${x.approved.id}`, expect: [404] },
|
||||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
|
// L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
|
||||||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
|
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []),
|
||||||
|
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []),
|
||||||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
|
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
|
||||||
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
|
// L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
|
||||||
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
|
{ path: "/planning", expect: [307], redirectTo: "/dashboard" },
|
||||||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
|
{ path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
|
||||||
// L14 Abrechnungsübersicht: keine Einträge von demo
|
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
|
||||||
{ path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] },
|
{ path: "/api/v1/planning/board", expect: [403] },
|
||||||
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []),
|
{ path: "/billing", expect: [307], redirectTo: "/dashboard" },
|
||||||
|
{ path: "/imports", expect: [307], redirectTo: "/dashboard" },
|
||||||
|
{ path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" },
|
||||||
|
{ path: "/api/v1/billing", expect: [403] },
|
||||||
|
{ path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] },
|
||||||
|
{ path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Basis", "im Paket Profi enthalten"] },
|
||||||
|
{ path: "/work-orders", mustContain: "DGUV" },
|
||||||
|
{ path: "/customers" },
|
||||||
|
{ path: "/reports" },
|
||||||
|
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []),
|
||||||
{ path: wo(x.d07, "?tab=billing"), expect: [404] },
|
{ path: wo(x.d07, "?tab=billing"), expect: [404] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
|
|||||||
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
|
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
|
||||||
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
|
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
|
||||||
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
|
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
|
||||||
|
put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Domain-Trennung craftvia.de ↔ app.craftvia.de (src/lib/marketing/hosts.ts)
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-host-routing.ts
|
||||||
|
|
||||||
|
import { hostSplit, routeByHost, type HostDecision } from "../src/lib/marketing/hosts";
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const env = { MARKETING_HOST: "craftvia.de", AUTH_URL: "https://app.craftvia.de" };
|
||||||
|
const split = hostSplit(env)!;
|
||||||
|
const route = (host: string, path: string, hasSession = false): HostDecision => {
|
||||||
|
const [pathname, query] = path.split("?");
|
||||||
|
return routeByHost({ host, pathname, search: query ? `?${query}` : "", hasSession }, split);
|
||||||
|
};
|
||||||
|
const to = (d: HostDecision) => (d.kind === "redirect" ? `${d.status} ${d.location}` : "next");
|
||||||
|
|
||||||
|
console.log("\n— Konfiguration —");
|
||||||
|
ok(hostSplit({ AUTH_URL: "https://app.craftvia.de" }) === null, "ohne MARKETING_HOST keine Trennung (lokal)");
|
||||||
|
ok(hostSplit({ MARKETING_HOST: "craftvia.de" }) === null, "ohne AUTH_URL keine Trennung");
|
||||||
|
ok(hostSplit({ MARKETING_HOST: "app.craftvia.de", AUTH_URL: "https://app.craftvia.de" }) === null, "gleiche Domain → keine Trennung");
|
||||||
|
ok(hostSplit({ MARKETING_HOST: "https://Craftvia.de/", AUTH_URL: "https://app.craftvia.de" })?.marketingHost === "craftvia.de", "MARKETING_HOST mit Schema/Slash/Großschreibung wird normalisiert");
|
||||||
|
|
||||||
|
console.log("\n— Website-Domain —");
|
||||||
|
ok(to(route("craftvia.de", "/")) === "next", "/ zeigt die Startseite");
|
||||||
|
ok(to(route("craftvia.de", "/funktionen/planung")) === "next", "Funktionsseiten bleiben auf der Website");
|
||||||
|
ok(to(route("craftvia.de", "/impressum")) === "next", "Impressum bleibt auf der Website");
|
||||||
|
ok(to(route("craftvia.de", "/login")) === "308 https://app.craftvia.de/login", "/login → App-Domain");
|
||||||
|
ok(to(route("craftvia.de", "/testen?ref=preise")) === "308 https://app.craftvia.de/testen?ref=preise", "/testen → App-Domain, Query bleibt erhalten");
|
||||||
|
ok(to(route("craftvia.de", "/dashboard")) === "308 https://app.craftvia.de/dashboard", "App-Seiten → App-Domain");
|
||||||
|
ok(to(route("craftvia.de", "/api/v1/sync")) === "308 https://app.craftvia.de/api/v1/sync", "API → App-Domain (308 behält die Methode)");
|
||||||
|
ok(to(route("craftvia.de:443", "/preise")) === "next", "Port im Host wird ignoriert");
|
||||||
|
ok(to(route("www.craftvia.de", "/preise")) === "308 https://craftvia.de/preise", "www → Hauptdomain");
|
||||||
|
|
||||||
|
console.log("\n— App-Domain —");
|
||||||
|
ok(to(route("app.craftvia.de", "/")) === "307 https://app.craftvia.de/login", "/ ohne Sitzung → Login");
|
||||||
|
ok(to(route("app.craftvia.de", "/", true)) === "next", "/ mit Sitzung → weiter (Seite leitet auf Dashboard bzw. /m)");
|
||||||
|
ok(to(route("app.craftvia.de", "/funktionen")) === "308 https://craftvia.de/funktionen", "Produktseiten → Website");
|
||||||
|
ok(to(route("app.craftvia.de", "/impressum")) === "308 https://craftvia.de/impressum", "Impressum → Website");
|
||||||
|
ok(to(route("app.craftvia.de", "/testen")) === "next", "Testphase bleibt in der App");
|
||||||
|
ok(to(route("app.craftvia.de", "/planning", true)) === "next", "App-Seiten bleiben in der App");
|
||||||
|
|
||||||
|
console.log("\n— Interne Aufrufe —");
|
||||||
|
ok(to(route("127.0.0.1:3000", "/")) === "next", "Healthcheck über 127.0.0.1 unverändert");
|
||||||
|
ok(to(route("craftvia-app:3000", "/login")) === "next", "Container-interne Aufrufe unverändert");
|
||||||
|
|
||||||
|
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ alle Prüfungen grün");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im
|
||||||
|
// moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState,
|
||||||
|
// requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.),
|
||||||
|
// isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust;
|
||||||
|
// nur der Betreiber ändert die Stufe.
|
||||||
|
// Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true)
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { prisma, dbForTenant } from "../src/server/db";
|
||||||
|
import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules";
|
||||||
|
import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan";
|
||||||
|
import { toErrorResponse } from "../src/server/api/respond";
|
||||||
|
import { applyOperations } from "../src/server/services/sync/apply";
|
||||||
|
import { getPlanningBoard } from "../src/server/services/planning/board";
|
||||||
|
import { getLiveSituation } from "../src/server/services/planning/live";
|
||||||
|
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
|
||||||
|
import { recommendAssignments } from "../src/server/services/planning/recommend";
|
||||||
|
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
|
||||||
|
import { getPlanningToday } from "../src/server/services/planning/summary";
|
||||||
|
import { runPlanningWatch } from "../src/server/services/planning/watch";
|
||||||
|
import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings";
|
||||||
|
import { billingModuleActive } from "../src/server/services/work-orders/transition";
|
||||||
|
import { updateTenantPlan } from "../src/server/services/plans/platform";
|
||||||
|
import { problemKey } from "../src/lib/offline/outbox-core";
|
||||||
|
import type { SyncOperationInput } from "../src/lib/sync/envelope";
|
||||||
|
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-pak-gates-a";
|
||||||
|
const SLUG_B = "zz-pak-gates-b";
|
||||||
|
const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test";
|
||||||
|
const READONLY_EMAIL = "readonly-pakete@zz-pakete.test";
|
||||||
|
|
||||||
|
const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session;
|
||||||
|
const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
|
||||||
|
|
||||||
|
function emergencyOp(): SyncOperationInput {
|
||||||
|
return {
|
||||||
|
clientOpId: randomUUID(),
|
||||||
|
opType: "emergency.create",
|
||||||
|
payload: {
|
||||||
|
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
|
||||||
|
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||||
|
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
|
||||||
|
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
|
||||||
|
reason: "Wasserrohrbruch im Keller",
|
||||||
|
},
|
||||||
|
clientCreatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extraCleanup(tenantIds: string[]) {
|
||||||
|
await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } });
|
||||||
|
await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } });
|
||||||
|
await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => {
|
||||||
|
await extraCleanup([]);
|
||||||
|
const A = await createTenant(SLUG_A);
|
||||||
|
const B = await createTenant(SLUG_B);
|
||||||
|
try {
|
||||||
|
await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } });
|
||||||
|
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
|
||||||
|
const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } });
|
||||||
|
|
||||||
|
section("Stufe am Mandanten");
|
||||||
|
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi");
|
||||||
|
|
||||||
|
section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)");
|
||||||
|
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`);
|
||||||
|
for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`);
|
||||||
|
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`);
|
||||||
|
let redirected = "";
|
||||||
|
try {
|
||||||
|
await requirePlanFeature(A.tenantId, "planning");
|
||||||
|
} catch (err) {
|
||||||
|
redirected = String((err as { digest?: string }).digest ?? (err as Error).message);
|
||||||
|
}
|
||||||
|
ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`);
|
||||||
|
ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect");
|
||||||
|
const modulesSrc = src("src/server/modules.ts");
|
||||||
|
ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis");
|
||||||
|
ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature");
|
||||||
|
|
||||||
|
section("Action (moduleGuard → assertModuleEnabled)");
|
||||||
|
let actionErr: unknown = null;
|
||||||
|
try {
|
||||||
|
await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing");
|
||||||
|
} catch (err) {
|
||||||
|
actionErr = err;
|
||||||
|
}
|
||||||
|
ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)");
|
||||||
|
const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } });
|
||||||
|
ok(!!denied, "verweigerter Zugriff wird auditiert");
|
||||||
|
for (const key of ["emergency", "imports", "lotse"]) {
|
||||||
|
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`);
|
||||||
|
}
|
||||||
|
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt");
|
||||||
|
ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt");
|
||||||
|
ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled");
|
||||||
|
|
||||||
|
section("API (requireApiContext → 403 mit Klartext)");
|
||||||
|
ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled");
|
||||||
|
const res = toErrorResponse(actionErr);
|
||||||
|
const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } };
|
||||||
|
ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext");
|
||||||
|
const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e);
|
||||||
|
const planningRes = toErrorResponse(planningErr);
|
||||||
|
ok(planningRes.status === 403, "Planungs-API (Basis) → 403");
|
||||||
|
|
||||||
|
section("Planung (Feature ohne eigenes Modul)");
|
||||||
|
ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja");
|
||||||
|
await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden");
|
||||||
|
await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden");
|
||||||
|
const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } });
|
||||||
|
await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden");
|
||||||
|
await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden");
|
||||||
|
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden");
|
||||||
|
ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet");
|
||||||
|
const watch = await runPlanningWatch(A.tenantId);
|
||||||
|
ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen");
|
||||||
|
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt");
|
||||||
|
ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt");
|
||||||
|
|
||||||
|
section("Lotse, Abrechnung, Navigation");
|
||||||
|
ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an");
|
||||||
|
ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)");
|
||||||
|
const effA = await effectiveModules(A.tenantId);
|
||||||
|
ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung");
|
||||||
|
const effB = await effectiveModules(B.tenantId);
|
||||||
|
ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar");
|
||||||
|
|
||||||
|
section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)");
|
||||||
|
const opA = emergencyOp();
|
||||||
|
const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
|
||||||
|
const rA = syncA.results[0];
|
||||||
|
ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`);
|
||||||
|
const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } });
|
||||||
|
ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)");
|
||||||
|
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt");
|
||||||
|
const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
|
||||||
|
ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)");
|
||||||
|
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)");
|
||||||
|
const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } });
|
||||||
|
const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() };
|
||||||
|
const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0];
|
||||||
|
ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected");
|
||||||
|
const opB = emergencyOp();
|
||||||
|
const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0];
|
||||||
|
ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`);
|
||||||
|
|
||||||
|
section("TenantModule aus + Profi → gesperrt");
|
||||||
|
await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } });
|
||||||
|
ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled");
|
||||||
|
let offErr: unknown = null;
|
||||||
|
try {
|
||||||
|
await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency");
|
||||||
|
} catch (err) {
|
||||||
|
offErr = err;
|
||||||
|
}
|
||||||
|
ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)");
|
||||||
|
const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0];
|
||||||
|
ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)");
|
||||||
|
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled");
|
||||||
|
await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } });
|
||||||
|
ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv");
|
||||||
|
|
||||||
|
section("Nur der Betreiber ändert die Stufe");
|
||||||
|
await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden");
|
||||||
|
await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden");
|
||||||
|
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid");
|
||||||
|
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid");
|
||||||
|
await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" });
|
||||||
|
const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
|
||||||
|
ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze");
|
||||||
|
ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze");
|
||||||
|
|
||||||
|
section("Wechsel Profi → Basis → Profi ohne Datenverlust");
|
||||||
|
await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
|
||||||
|
const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
|
||||||
|
await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } });
|
||||||
|
const snapshot = async () => ({
|
||||||
|
emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }),
|
||||||
|
seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }),
|
||||||
|
messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }),
|
||||||
|
teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }),
|
||||||
|
});
|
||||||
|
const before = await snapshot();
|
||||||
|
const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true });
|
||||||
|
ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit");
|
||||||
|
const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } });
|
||||||
|
ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after");
|
||||||
|
ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet");
|
||||||
|
await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt");
|
||||||
|
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert");
|
||||||
|
await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false });
|
||||||
|
ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da");
|
||||||
|
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden");
|
||||||
|
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt");
|
||||||
|
|
||||||
|
section("Mandantentrennung der Stufe");
|
||||||
|
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt");
|
||||||
|
ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht");
|
||||||
|
} finally {
|
||||||
|
await extraCleanup([A.tenantId, B.tenantId]);
|
||||||
|
await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
|
||||||
|
await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// L17 Pakete — reine Stufenregeln (src/lib/plans.ts), ohne Datenbank.
|
||||||
|
// Lauf: npx tsx scripts/test-pakete-rules.ts
|
||||||
|
|
||||||
|
import {
|
||||||
|
chatOverage,
|
||||||
|
chatQuota,
|
||||||
|
DEFAULT_TENANT_TIER,
|
||||||
|
effectiveTier,
|
||||||
|
isFeatureInTier,
|
||||||
|
isModuleInTier,
|
||||||
|
isQuotaBlocked,
|
||||||
|
isTenantTier,
|
||||||
|
LOTSE_CHAT_CHATS_PER_SEAT,
|
||||||
|
LOTSE_CHAT_OVERAGE_PACK,
|
||||||
|
modulesLockedByTier,
|
||||||
|
monthKeyOfDay,
|
||||||
|
nextMonthKey,
|
||||||
|
overagePacks,
|
||||||
|
previousMonthKey,
|
||||||
|
PROFI_ONLY_MODULES,
|
||||||
|
usageLevel,
|
||||||
|
usagePercent,
|
||||||
|
} from "../src/lib/plans";
|
||||||
|
import { MODULE_KEYS } from "../src/lib/modules";
|
||||||
|
import { NAV_ITEMS, visibleNavItems } from "../src/lib/nav";
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("— Stufen —");
|
||||||
|
ok(DEFAULT_TENANT_TIER === "PROFI", "Default-Stufe ist Profi");
|
||||||
|
ok(isTenantTier("BASIS") && isTenantTier("PROFI") && !isTenantTier("GOLD") && !isTenantTier(null), "isTenantTier erkennt nur BASIS/PROFI");
|
||||||
|
ok(effectiveTier("BASIS", "TRIAL") === "PROFI", "Testphase läuft immer als Profi");
|
||||||
|
ok(effectiveTier("BASIS", "FULL") === "BASIS" && effectiveTier("PROFI", "FULL") === "PROFI", "Vollversion: gespeicherte Stufe gilt");
|
||||||
|
ok(effectiveTier(undefined, "FULL") === "PROFI", "unbekannte Stufe → Default Profi");
|
||||||
|
|
||||||
|
console.log("\n— Module je Stufe —");
|
||||||
|
const profiOnly = ["billing", "emergency", "imports", "lotse"];
|
||||||
|
ok(JSON.stringify([...PROFI_ONLY_MODULES].sort()) === JSON.stringify([...profiOnly].sort()), "Nur-Profi-Module: billing, emergency, imports, lotse");
|
||||||
|
for (const key of MODULE_KEYS) {
|
||||||
|
ok(isModuleInTier("PROFI", key), `Profi enthält ${key}`);
|
||||||
|
ok(isModuleInTier("BASIS", key) === !profiOnly.includes(key), `Basis ${profiOnly.includes(key) ? "sperrt" : "enthält"} ${key}`);
|
||||||
|
}
|
||||||
|
ok(modulesLockedByTier("PROFI").length === 0 && modulesLockedByTier("BASIS").length === 4, "modulesLockedByTier");
|
||||||
|
ok(!isFeatureInTier("BASIS", "planning") && !isFeatureInTier("BASIS", "lotse_chat"), "Basis: keine Planung, kein Lotse-Chat");
|
||||||
|
ok(isFeatureInTier("PROFI", "planning") && isFeatureInTier("PROFI", "lotse_chat"), "Profi: Planung und Lotse-Chat");
|
||||||
|
|
||||||
|
console.log("\n— Kontingent —");
|
||||||
|
ok(LOTSE_CHAT_CHATS_PER_SEAT === 150 && LOTSE_CHAT_OVERAGE_PACK === 100, "150 Chats je Platz, Mehrverbrauch in Paketen à 100");
|
||||||
|
ok(chatQuota(3) === 450 && chatQuota(0) === 0 && chatQuota(-2) === 0, "Kontingent = Plätze × 150");
|
||||||
|
ok(chatOverage(212, 450) === 0 && chatOverage(460, 450) === 10, "Mehrverbrauch = über dem Kontingent");
|
||||||
|
ok(overagePacks(0) === 0 && overagePacks(1) === 1 && overagePacks(100) === 1 && overagePacks(101) === 2 && overagePacks(250) === 3, "angefangene 100er-Pakete zählen");
|
||||||
|
ok(usageLevel(212, 450) === "ok", "212/450 → ok");
|
||||||
|
ok(usageLevel(360, 450) === "warn" && usageLevel(359, 450) === "ok", "ab 80 % → warn");
|
||||||
|
ok(usageLevel(450, 450) === "exhausted" && usageLevel(451, 450) === "over", "ausgeschöpft / Mehrverbrauch");
|
||||||
|
ok(usageLevel(0, 0) === "ok" && usageLevel(1, 0) === "over", "ohne Kontingent zählt jede Nutzung als Mehrverbrauch");
|
||||||
|
ok(usagePercent(212, 450) === 47 && usagePercent(900, 450) === 100 && usagePercent(0, 0) === 0, "Balken in Prozent (gedeckelt)");
|
||||||
|
ok(!isQuotaBlocked(500, 450, false), "ohne hartes Limit wird weitergezählt");
|
||||||
|
ok(isQuotaBlocked(450, 450, true) && !isQuotaBlocked(449, 450, true), "hartes Limit sperrt ab dem Kontingent");
|
||||||
|
|
||||||
|
console.log("\n— Monate —");
|
||||||
|
ok(monthKeyOfDay("2026-09-21") === "2026-09", "Monat eines Tags");
|
||||||
|
ok(previousMonthKey("2026-01") === "2025-12" && previousMonthKey("2026-09") === "2026-08", "Vormonat inkl. Jahreswechsel");
|
||||||
|
ok(nextMonthKey("2026-12") === "2027-01" && nextMonthKey("2026-09") === "2026-10", "Folgemonat inkl. Jahreswechsel");
|
||||||
|
|
||||||
|
console.log("\n— Navigation —");
|
||||||
|
const all = NAV_ITEMS.flatMap((i) => i.permissions ?? []);
|
||||||
|
const basisNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(profiOnly), lockedFeatures: new Set(["planning", "lotse_chat"]), permissions: all });
|
||||||
|
const profiNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(), lockedFeatures: new Set(), permissions: all });
|
||||||
|
ok(!basisNav.some((i) => i.href.startsWith("/planning")), "Basis: keine Planung in der Navigation");
|
||||||
|
ok(!basisNav.some((i) => ["/billing", "/imports", "/work-orders/emergency-review"].includes(i.href)), "Basis: keine Abrechnung/Import/Notdienst-Prüfung");
|
||||||
|
ok(basisNav.some((i) => i.href === "/work-orders") && basisNav.some((i) => i.href === "/work-orders/time-approvals") && basisNav.some((i) => i.href === "/reports"), "Basis: Aufträge, Zeitfreigabe, Berichte bleiben");
|
||||||
|
ok(profiNav.some((i) => i.href === "/planning") && profiNav.some((i) => i.href === "/billing"), "Profi: Planung und Abrechnung sichtbar");
|
||||||
|
|
||||||
|
console.log(failures === 0 ? "\nAlle Prüfungen grün." : `\n${failures} FEHLER.`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// L17 Pakete — Lotse-Chat pro Nutzer: Platzvergabe bis zur Grenze (darüber invalid), Chat ohne Platz →
|
||||||
|
// blocked no_seat, mit Platz → ok, Testphase ohne Platzprüfung, Kontingentzählung über die Monatsgrenze
|
||||||
|
// (Zeitzone), Mehrverbrauch weiterzählen, hartes Limit → blocked quota_exhausted, Feldrollen vergeben
|
||||||
|
// keine Plätze, Mandantentrennung (B sieht/ändert keine Plätze/Zählung von A), Betreiber-Übersicht.
|
||||||
|
// Lauf: npx tsx scripts/test-pakete-seats.ts (auch mit RLS_ENFORCED=true)
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { prisma, dbForTenant } from "../src/server/db";
|
||||||
|
import { FakeLotseChatProvider } from "../src/server/ai/lotse/chat-fake";
|
||||||
|
import { assertLotseChatUsable, canUseLotseChat, isChatQuotaBlocked } from "../src/server/services/lotse/chat/access";
|
||||||
|
import { getChatView } from "../src/server/services/lotse/chat/conversations";
|
||||||
|
import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
|
||||||
|
import { getSeatOverview, listEligibleUsers, setChatSeat } from "../src/server/services/lotse/chat/seats";
|
||||||
|
import { getChatUsage, getChatUsageForAdmin, monthBounds, monthKeyOf } from "../src/server/services/lotse/chat/usage";
|
||||||
|
import { getTenantPlanOverview, updateTenantPlan } from "../src/server/services/plans/platform";
|
||||||
|
import { ServiceError } from "../src/server/services/context";
|
||||||
|
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||||
|
|
||||||
|
const SLUG_A = "zz-pak-seats-a";
|
||||||
|
const SLUG_B = "zz-pak-seats-b";
|
||||||
|
const PLATFORM_EMAIL = "platform-seats@zz-pakete.test";
|
||||||
|
|
||||||
|
async function reasonOf(p: Promise<unknown>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await p;
|
||||||
|
return "ok";
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) return `${err.code}/${(err.details as { reason?: string } | undefined)?.reason ?? err.message}`;
|
||||||
|
return `error:${(err as Error).message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extraCleanup(tenantIds: string[]) {
|
||||||
|
const w = { where: { tenantId: { in: tenantIds } } };
|
||||||
|
await prisma.lotseChatSeat.deleteMany(w);
|
||||||
|
await prisma.lotseActionProposal.deleteMany(w);
|
||||||
|
await prisma.lotseMessage.deleteMany(w);
|
||||||
|
await prisma.lotseConversation.deleteMany(w);
|
||||||
|
await prisma.platformAdmin.deleteMany({ where: { email: PLATFORM_EMAIL } });
|
||||||
|
}
|
||||||
|
|
||||||
|
runSuite("L17 Pakete – Lotse-Chat-Plätze & Kontingent", [SLUG_A, SLUG_B], async () => {
|
||||||
|
await extraCleanup([]);
|
||||||
|
const A = await createTenant(SLUG_A);
|
||||||
|
const B = await createTenant(SLUG_B);
|
||||||
|
try {
|
||||||
|
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
|
||||||
|
const actor = { platformAdminId: platform.id };
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
|
||||||
|
await updateTenantPlan(actor, B.tenantId, { tier: "PROFI", lotseChatSeats: 5, lotseChatHardLimit: false });
|
||||||
|
|
||||||
|
section("Berechtigte Nutzer (Feldrolle: lotse:use + field:execute)");
|
||||||
|
const eligible = (await listEligibleUsers(A.ctx.admin)).map((u) => u.id);
|
||||||
|
const u = A.users;
|
||||||
|
ok([u.tech.id, u.tech2.id, u.outsider.id, u.lead.id, u.lead2.id].every((id) => eligible.includes(id)), "Monteure und Teamleiter berechtigt");
|
||||||
|
ok(!eligible.includes(u.backoffice.id), "Backoffice ohne field:execute nicht berechtigt");
|
||||||
|
ok(eligible.includes(u.admin.id), "Mandantenadministrator hat beide Rechte → berechtigt (Regel = Rechte, nicht Rollenname)");
|
||||||
|
|
||||||
|
section("Chat ohne Platz → blocked no_seat");
|
||||||
|
ok(!(await canUseLotseChat(A.ctx.tech)), "canUseLotseChat ohne Platz → false (kein Navigationseintrag)");
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/no_seat", "assertLotseChatUsable → blocked no_seat");
|
||||||
|
ok((await reasonOf(getChatView(A.ctx.tech))) === "blocked/no_seat", "direkter Aufruf /m/lotse (getChatView) → no_seat");
|
||||||
|
const provider = () => new FakeLotseChatProvider([{ text: "Alles klar." }]);
|
||||||
|
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Hallo" }, { provider: provider() }))) === "blocked/no_seat", "Senden ohne Platz → no_seat");
|
||||||
|
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === 0, "ohne Platz wird nichts gespeichert");
|
||||||
|
|
||||||
|
section("Platzvergabe bis zur Grenze");
|
||||||
|
const r1 = await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true });
|
||||||
|
ok(r1.changed, "Platz 1 an Monteur vergeben");
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
|
||||||
|
const overview = await getSeatOverview(A.ctx.admin);
|
||||||
|
ok(overview.purchased === 2 && overview.assigned === 2 && !overview.overbooked, "2 von 2 Plätzen vergeben");
|
||||||
|
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }))) === "invalid/no_seats_left", "dritter Platz → invalid no_seats_left");
|
||||||
|
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Grenze serverseitig gehalten");
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
|
||||||
|
const parallel = await Promise.allSettled([u.tech2.id, u.outsider.id].map((userId) => setChatSeat(A.ctx.admin, { userId, assigned: true })));
|
||||||
|
ok(parallel.filter((p) => p.status === "fulfilled").length === 1 && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "ein freier Platz, zwei parallele Vergaben → genau eine gelingt");
|
||||||
|
for (const userId of [u.tech2.id, u.outsider.id]) await setChatSeat(A.ctx.admin, { userId, assigned: false });
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
|
||||||
|
ok(!(await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true })).changed, "erneutes Vergeben idempotent");
|
||||||
|
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: false }))) === "ok", "Entzug ohne Platz idempotent");
|
||||||
|
const auditCreate = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.tech.id, action: "create" } });
|
||||||
|
ok(!!auditCreate && (auditCreate.after as { userId?: string })?.userId === u.tech.id && auditCreate.actorId === u.admin.id, "Audit bei Vergabe (after)");
|
||||||
|
|
||||||
|
section("Chat mit Platz → ok");
|
||||||
|
ok(await canUseLotseChat(A.ctx.tech), "canUseLotseChat mit Platz → true");
|
||||||
|
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Chat-Ansicht mit Platz");
|
||||||
|
const view = await sendLotseMessage(A.ctx.tech, { text: "Wie ist der Stand?" }, { provider: provider() });
|
||||||
|
ok(view.messages.some((m) => m.role === "user"), "Nachricht mit Platz gesendet");
|
||||||
|
ok((await canUseLotseChat(A.ctx.lead)) && !(await canUseLotseChat(A.ctx.tech2)), "Teamleiter mit Platz ja, Monteur ohne Platz nein");
|
||||||
|
|
||||||
|
section("Entzug + Überbelegung");
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.lead))) === "blocked/no_seat", "nach Entzug → no_seat");
|
||||||
|
const auditDelete = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.lead.id, action: "delete" } });
|
||||||
|
ok(!!auditDelete && (auditDelete.before as { userId?: string })?.userId === u.lead.id, "Audit bei Entzug (before)");
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 1, lotseChatHardLimit: false });
|
||||||
|
const over = await getSeatOverview(A.ctx.admin);
|
||||||
|
ok(over.overbooked && over.users.find((x) => x.id === u.tech.id)?.valid === true && over.users.find((x) => x.id === u.tech2.id)?.valid === false, "Betreiber senkt auf 1: nur der zuerst vergebene Platz gilt");
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech2))) === "blocked/no_seat", "überzähliger Platz → no_seat");
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
|
||||||
|
ok(await canUseLotseChat(A.ctx.tech2), "wieder 2 Plätze → beide nutzbar");
|
||||||
|
|
||||||
|
section("Feldrollen und Backoffice vergeben keine Plätze");
|
||||||
|
for (const p of ["tech", "lead", "backoffice"] as const) {
|
||||||
|
await expectCode(() => setChatSeat(A.ctx[p], { userId: u.outsider.id, assigned: true }), "forbidden", `${p} → forbidden`);
|
||||||
|
await expectCode(() => getSeatOverview(A.ctx[p]), "forbidden", `${p} sieht die Platzverwaltung nicht`);
|
||||||
|
await expectCode(() => getChatUsageForAdmin(A.ctx[p]), "forbidden", `${p} sieht den Verbrauch nicht`);
|
||||||
|
}
|
||||||
|
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: true }))) === "invalid/not_eligible", "Platz an Nutzer ohne Feldrolle → invalid not_eligible");
|
||||||
|
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "DEACTIVATED" } });
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: false });
|
||||||
|
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.outsider.id, assigned: true }))) === "invalid/not_eligible", "deaktivierter Nutzer → invalid not_eligible");
|
||||||
|
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "ACTIVE" } });
|
||||||
|
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
|
||||||
|
|
||||||
|
section("Mandantentrennung");
|
||||||
|
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: true }))) === "invalid/not_eligible", "B vergibt keinen Platz an Nutzer aus A");
|
||||||
|
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: false }))) === "ok" && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId, userId: u.tech.id } })) === 1, "B kann den Platz von A nicht entziehen");
|
||||||
|
const bOverview = await getSeatOverview(B.ctx.admin);
|
||||||
|
ok(!bOverview.users.some((x) => [u.tech.id, u.tech2.id].includes(x.id)) && bOverview.assigned === 0, "B sieht keine Plätze/Nutzer von A");
|
||||||
|
ok((await dbForTenant(B.tenantId).lotseChatSeat.findMany()).length === 0, "Tenant-Client von B findet keine Platz-Zeilen von A");
|
||||||
|
ok((await getChatUsageForAdmin(B.ctx.admin)).used === 0, "B zählt keine Chats von A");
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(B.ctx.tech))) === "blocked/no_seat", "B-Monteur hat keinen Platz (Plätze gelten je Mandant)");
|
||||||
|
|
||||||
|
section("Kontingentzählung über die Monatsgrenze (Zeitzone Europe/Berlin)");
|
||||||
|
const tz = "Europe/Berlin";
|
||||||
|
ok(monthKeyOf(new Date("2026-02-28T23:30:00Z"), tz) === "2026-03" && monthKeyOf(new Date("2026-02-28T22:30:00Z"), tz) === "2026-02", "Monat in Mandanten-Zeitzone");
|
||||||
|
const march = monthBounds("2026-03", tz);
|
||||||
|
ok(march.from.toISOString() === "2026-02-28T23:00:00.000Z" && march.to.toISOString() === "2026-03-31T22:00:00.000Z", "Monatsgrenzen inkl. Sommerzeit");
|
||||||
|
const conv = await prisma.lotseConversation.create({ data: { tenantId: A.tenantId, userId: u.tech2.id } });
|
||||||
|
const msg = (createdAt: string, role: "user" | "assistant" = "user") => ({ tenantId: A.tenantId, conversationId: conv.id, role, text: "x", createdAt: new Date(createdAt) });
|
||||||
|
await prisma.lotseMessage.createMany({
|
||||||
|
data: [msg("2026-02-28T22:30:00Z"), msg("2026-02-28T23:30:00Z"), msg("2026-02-28T23:31:00Z", "assistant"), msg("2026-03-31T21:59:00Z"), msg("2026-03-31T22:00:00Z")],
|
||||||
|
});
|
||||||
|
const feb = await getChatUsage(A.ctx.admin, { monthKey: "2026-02" });
|
||||||
|
const mar = await getChatUsage(A.ctx.admin, { monthKey: "2026-03" });
|
||||||
|
const apr = await getChatUsage(A.ctx.admin, { monthKey: "2026-04" });
|
||||||
|
ok(feb.used === 1 && mar.used === 2 && apr.used === 1, `Zählung je Monat (Feb ${feb.used}, Mär ${mar.used}, Apr ${apr.used}); Lotse-Antworten zählen nicht`);
|
||||||
|
ok(mar.perUser.length === 1 && mar.perUser[0].userId === u.tech2.id && mar.perUser[0].count === 2, "Aufschlüsselung je Nutzer");
|
||||||
|
|
||||||
|
section("Mehrverbrauch weiterzählen, hartes Limit");
|
||||||
|
const now = new Date();
|
||||||
|
const current = await getChatUsage(A.ctx.admin, { now });
|
||||||
|
ok(current.quota === 300 && current.quotaBasis === 2, `Kontingent = 2 vergebene Plätze × 150 (${current.quota})`);
|
||||||
|
const fill = 300 - current.used;
|
||||||
|
const recent = new Date(Math.max(new Date(current.from).getTime() + 60_000, now.getTime() - 60_000));
|
||||||
|
await prisma.lotseMessage.createMany({ data: Array.from({ length: fill + 5 }, () => ({ tenantId: A.tenantId, conversationId: conv.id, role: "user" as const, text: "y", createdAt: recent })) });
|
||||||
|
const overUsage = await getChatUsage(A.ctx.admin, { now });
|
||||||
|
ok(overUsage.used === 305 && overUsage.overage === 5 && overUsage.overagePacks === 1 && overUsage.level === "over" && !overUsage.blocked, "305/300: Mehrverbrauch 5 → 1 Paket, nicht gesperrt");
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "ok", "ohne hartes Limit: Senden weiter möglich");
|
||||||
|
const sent = await sendLotseMessage(A.ctx.tech, { text: "Noch eine Frage" }, { provider: provider(), now: () => now });
|
||||||
|
ok(sent.messages.length > 0 && (await getChatUsage(A.ctx.admin, { now })).used === 306, "Mehrverbrauch wird weitergezählt (306)");
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: true });
|
||||||
|
ok(await isChatQuotaBlocked(A.ctx.tech, now), "hartes Limit → Kontingent gesperrt");
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "blocked/quota_exhausted", "Senden → blocked quota_exhausted");
|
||||||
|
const beforeCount = await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } });
|
||||||
|
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Gesperrt?" }, { provider: provider(), now: () => now }))) === "blocked/quota_exhausted", "sendLotseMessage → quota_exhausted");
|
||||||
|
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === beforeCount, "gesperrte Nachricht wird nicht gespeichert/gezählt");
|
||||||
|
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Verlauf bleibt lesbar (Hinweis statt Eingabefeld)");
|
||||||
|
ok(!(await isChatQuotaBlocked(B.ctx.tech, now)), "hartes Limit von A wirkt nicht auf B");
|
||||||
|
|
||||||
|
section("Testphase: ohne Platzprüfung, Kontingent = Berechtigte × 150");
|
||||||
|
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "TRIAL", trialStartedAt: new Date(), trialEndsAt: new Date(Date.now() + 10 * 86400_000), tier: "BASIS", lotseChatHardLimit: true } });
|
||||||
|
ok((await canUseLotseChat(B.ctx.tech)) && (await canUseLotseChat(B.ctx.outsider)), "Testphase: jeder berechtigte Nutzer darf chatten (auch bei gespeicherter Stufe Basis)");
|
||||||
|
const trialUsage = await getChatUsage(B.ctx.admin, { now });
|
||||||
|
const eligibleB = (await listEligibleUsers(B.ctx.admin)).length;
|
||||||
|
ok(trialUsage.isTrial && trialUsage.quota === eligibleB * 150 && !trialUsage.hardLimit, `Testphase: Kontingent ${eligibleB} × 150, kein hartes Limit`);
|
||||||
|
ok((await codeOf(sendLotseMessage(B.ctx.outsider, { text: "Test" }, { provider: provider() }))) === "ok", "Testphase: Senden ohne Platz");
|
||||||
|
ok((await codeOf(assertLotseChatUsable(B.ctx.backoffice))) === "forbidden", "Testphase: ohne Feldrolle weiterhin kein Chat");
|
||||||
|
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "FULL", trialStartedAt: null, trialEndsAt: null, tier: "PROFI", lotseChatHardLimit: false } });
|
||||||
|
|
||||||
|
section("Basis sperrt den Chat, Plätze bleiben");
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "BASIS", lotseChatSeats: 2, lotseChatHardLimit: false });
|
||||||
|
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/not_in_plan", "Basis → blocked not_in_plan");
|
||||||
|
ok(!(await canUseLotseChat(A.ctx.tech)), "Basis: kein „Lotse fragen“/Navigationseintrag");
|
||||||
|
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Plätze bleiben gespeichert");
|
||||||
|
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }))) === "blocked/not_in_plan", "Basis: keine neue Vergabe");
|
||||||
|
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
|
||||||
|
ok(await canUseLotseChat(A.ctx.tech), "zurück auf Profi: Chat mit bestehendem Platz wieder nutzbar");
|
||||||
|
|
||||||
|
section("Betreiber-Übersicht (Grundlage der Rechnung)");
|
||||||
|
const op = await getTenantPlanOverview(actor, A.tenantId, now);
|
||||||
|
ok(op.plan.lotseChatSeats === 2 && op.seatsAssigned === 2 && op.current.used === 306 && op.current.overage === 6 && op.current.overagePacks === 1, `laufender Monat: 306 Chats, Mehrverbrauch 6 → 1 Paket`);
|
||||||
|
ok(op.previous.monthKey !== op.current.monthKey && op.previous.used >= 0, `Vormonat ${op.previous.monthKey}: ${op.previous.used} Chats`);
|
||||||
|
await expectCode(() => getTenantPlanOverview({ platformAdminId: u.admin.id }, A.tenantId), "forbidden", "Mandanten-Admin hat keine Betreiber-Übersicht");
|
||||||
|
} finally {
|
||||||
|
await extraCleanup([A.tenantId, B.tenantId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
|
|||||||
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
import { GettingStarted } from "@/components/trial/getting-started";
|
import { GettingStarted } from "@/components/trial/getting-started";
|
||||||
|
import { ProfiHint } from "@/components/plans/profi-hint";
|
||||||
|
import { effectiveModules } from "@/server/plan";
|
||||||
|
|
||||||
type SP = Record<string, string | string[] | undefined>;
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
@@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
|||||||
const t = await getTranslations("dashboard");
|
const t = await getTranslations("dashboard");
|
||||||
const tw = await getTranslations("workOrders");
|
const tw = await getTranslations("workOrders");
|
||||||
|
|
||||||
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
|
// L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
|
||||||
const moduleEnabled = !moduleRow || moduleRow.enabled;
|
const modules = await effectiveModules(ctx.tenantId);
|
||||||
|
const moduleEnabled = !modules.inactive.has("work_orders");
|
||||||
|
|
||||||
const f = parseListParams(sp);
|
const f = parseListParams(sp);
|
||||||
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
|
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
|
||||||
@@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
|||||||
{t("moduleDisabled")}
|
{t("moduleDisabled")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
|
||||||
|
|
||||||
{!tiles ? (
|
{!tiles ? (
|
||||||
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
|
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
|
||||||
@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
|
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
|
||||||
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
||||||
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { signOut } from "@/server/auth";
|
import { signOut } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { requireAppAccess } from "@/server/app-access";
|
import { requireAppAccess } from "@/server/app-access";
|
||||||
|
import { effectiveModules } from "@/server/plan";
|
||||||
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
||||||
import { resolveTenantBranding } from "@/lib/brand";
|
import { resolveTenantBranding } from "@/lib/brand";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -30,17 +31,18 @@ export default async function AppLayout({
|
|||||||
|
|
||||||
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
||||||
// Seiten/Actions prüfen serverseitig selbst).
|
// Seiten/Actions prüfen serverseitig selbst).
|
||||||
const [moduleRows, brandingSettings] = await Promise.all([
|
// L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
|
||||||
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
const [modules, brandingSettings] = await Promise.all([
|
||||||
|
effectiveModules(session.user.tenantId),
|
||||||
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
||||||
where: { tenantId: session.user.tenantId },
|
where: { tenantId: session.user.tenantId },
|
||||||
select: { accent: true },
|
select: { accent: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const branding = resolveTenantBranding(brandingSettings);
|
const branding = resolveTenantBranding(brandingSettings);
|
||||||
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
|
||||||
const navItems = visibleNavItems(NAV_ITEMS, {
|
const navItems = visibleNavItems(NAV_ITEMS, {
|
||||||
disabledModules,
|
disabledModules: modules.inactive,
|
||||||
|
lockedFeatures: modules.lockedFeatures,
|
||||||
permissions: session.user.permissions ?? [],
|
permissions: session.user.permissions ?? [],
|
||||||
});
|
});
|
||||||
const mainNav = navItems.filter((i) => i.section === "main");
|
const mainNav = navItems.filter((i) => i.section === "main");
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { requireModule } from "@/server/modules";
|
import { requireModule } from "@/server/modules";
|
||||||
|
import { requirePlanFeature } from "@/server/plan";
|
||||||
|
|
||||||
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
|
/** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
|
||||||
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
await requireModule("work_orders");
|
const session = await requireModule("work_orders");
|
||||||
|
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
||||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||||
|
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
|
||||||
import { PageHead } from "@/components/mockup-ui";
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { saveLotseSettings } from "@/server/actions/lotse-settings";
|
import { saveLotseSettings } from "@/server/actions/lotse-settings";
|
||||||
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
|
|||||||
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
|
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
|
||||||
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
|
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
|
||||||
*/
|
*/
|
||||||
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
|
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
|
||||||
const ctx = await readCtx();
|
const ctx = await readCtx();
|
||||||
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
||||||
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
|
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
|
||||||
@@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
|||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
|
||||||
|
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
|
|||||||
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
||||||
import { ShieldCheck, History } from "lucide-react";
|
import { ShieldCheck, History } from "lucide-react";
|
||||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||||
|
import { effectiveModules } from "@/server/plan";
|
||||||
|
|
||||||
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
|
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
|
||||||
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
|
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
|
||||||
@@ -28,9 +29,9 @@ export default async function SettingsPage({
|
|||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
|
|
||||||
const [s, moduleRows] = await Promise.all([
|
const [s, modules] = await Promise.all([
|
||||||
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||||
db.tenantModule.findMany(),
|
effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
|
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
|
||||||
@@ -50,7 +51,7 @@ export default async function SettingsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
|
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
|
||||||
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
const disabled = modules.inactive;
|
||||||
const v = (x?: string | null) => x ?? "";
|
const v = (x?: string | null) => x ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
|
|||||||
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||||
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
import { reasonRequired } from "@/server/services/work-orders/transition";
|
import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
|
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
|
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
|
||||||
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
|
const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
|
||||||
const billingOverviewActive = !billingModule || billingModule.enabled;
|
|
||||||
const billingTransitions = transitions.filter(
|
const billingTransitions = transitions.filter(
|
||||||
(to) =>
|
(to) =>
|
||||||
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
|
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
|
||||||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<LinkTabs
|
<LinkTabs
|
||||||
label={t("detail.tabs.overview")}
|
label={t("detail.tabs.overview")}
|
||||||
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
|
items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
|
{tab === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
|
||||||
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
|
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
|
||||||
{tab === "history" && <HistoryTab {...tabProps} />}
|
{tab === "history" && <HistoryTab {...tabProps} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { requireSession } from "@/server/auth";
|
||||||
import { requireModule } from "@/server/modules";
|
import { requireModule } from "@/server/modules";
|
||||||
|
import { moduleState } from "@/server/plan";
|
||||||
|
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
|
||||||
|
|
||||||
/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
|
/**
|
||||||
|
* Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx.
|
||||||
|
* L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard.
|
||||||
|
*/
|
||||||
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return <LotseChatUnavailable reason="not_in_plan" />;
|
||||||
await requireModule("lotse");
|
await requireModule("lotse");
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ChevronLeft, CircleSlash } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import type { ChatView } from "@/lib/lotse/chat";
|
import type { ChatView } from "@/lib/lotse/chat";
|
||||||
import { isAiConfigured } from "@/server/ai/client";
|
import { isAiConfigured } from "@/server/ai/client";
|
||||||
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
||||||
import { ServiceError } from "@/server/services/context";
|
import { ServiceError } from "@/server/services/context";
|
||||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access";
|
||||||
import { getChatView } from "@/server/services/lotse/chat/conversations";
|
import { getChatView } from "@/server/services/lotse/chat/conversations";
|
||||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||||
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
|
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
|
||||||
import { card } from "@/components/field/ui";
|
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
|
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
|
||||||
@@ -27,24 +28,11 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
|
|||||||
if (!(err instanceof ServiceError)) throw err;
|
if (!(err instanceof ServiceError)) throw err;
|
||||||
if (err.code === "not_found") notFound();
|
if (err.code === "not_found") notFound();
|
||||||
const reason = (err.details as { reason?: string } | undefined)?.reason;
|
const reason = (err.details as { reason?: string } | undefined)?.reason;
|
||||||
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
|
const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
|
||||||
return (
|
return <LotseChatUnavailable reason={code} />;
|
||||||
<main className="space-y-3 p-4">
|
|
||||||
<section className={card}>
|
|
||||||
<h1 className="flex items-center gap-2 text-[20px]">
|
|
||||||
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
|
|
||||||
{t("chat.unavailable.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
|
|
||||||
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
|
|
||||||
{t("chat.unavailable.back")}
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeZone = await tenantTimezone(ctx);
|
const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
|
||||||
return (
|
return (
|
||||||
<main className="space-y-3 p-4">
|
<main className="space-y-3 p-4">
|
||||||
{view.workOrder && (
|
{view.workOrder && (
|
||||||
@@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
|
|||||||
transcription={transcriptionConfig().configured}
|
transcription={transcriptionConfig().configured}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
|
quotaExhausted={quotaExhausted}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context";
|
|||||||
import { getMyActiveSession } from "@/server/services/field/sessions";
|
import { getMyActiveSession } from "@/server/services/field/sessions";
|
||||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||||
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
|
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
|
||||||
|
import { isModuleActive } from "@/server/plan";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
@@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
|||||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||||
|
|
||||||
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
||||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
|
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
|
||||||
try {
|
try {
|
||||||
const ctx = await fieldPageContext();
|
const ctx = await fieldPageContext();
|
||||||
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
|
const [session, approvals, lotseChat, emergency] = await Promise.all([
|
||||||
|
can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
|
||||||
|
countPendingTimeEntries(ctx),
|
||||||
|
canUseLotseChat(ctx),
|
||||||
|
isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
clock: session
|
clock: session
|
||||||
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
|
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
|
||||||
: null,
|
: null,
|
||||||
approvals,
|
approvals,
|
||||||
lotseChat, // L16
|
lotseChat, // L16 (L17: inkl. Chat-Platz)
|
||||||
|
emergency,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// module "field" disabled or no field permissions: shell without clock/badge
|
// module "field" disabled or no field permissions: shell without clock/badge
|
||||||
return { clock: null, approvals: 0, lotseChat: false };
|
return { clock: null, approvals: 0, lotseChat: false, emergency: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
|
|||||||
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
|
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
|
||||||
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||||
<RunningClockBar initial={time.clock} />
|
<RunningClockBar initial={time.clock} />
|
||||||
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
|
<BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { CtaBand, PageHero, PointList, ShotFrame } from "@/components/marketing/blocks";
|
||||||
|
import { FEATURE_PAGES, FEATURE_SLUGS, isFeatureSlug } from "@/lib/marketing/pages";
|
||||||
|
|
||||||
|
/** Detailseite eines Moduls — Inhalt aus `marketing.pages.<slug>`, Bilder aus public/marketing. */
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return FEATURE_SLUGS.map((slug) => ({ slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
if (!isFeatureSlug(slug)) return {};
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
return { title: t(`pages.${slug}.title`), description: t(`pages.${slug}.lead`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FeatureDetailPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
if (!isFeatureSlug(slug)) notFound();
|
||||||
|
const page = FEATURE_PAGES[slug];
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
const points = Array.from({ length: page.points }, (_, i) => t(`pages.${slug}.p${i + 1}`));
|
||||||
|
const cards = Array.from({ length: page.cards }, (_, i) => ({
|
||||||
|
title: t(`pages.${slug}.c${i + 1}title`),
|
||||||
|
text: t(`pages.${slug}.c${i + 1}text`),
|
||||||
|
}));
|
||||||
|
const [first, second] = page.shots;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero eyebrow={t("nav.features")} title={t(`pages.${slug}.title`)} lead={t(`pages.${slug}.lead`)} />
|
||||||
|
|
||||||
|
<section aria-label={t(`pages.${slug}.title`)} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||||
|
<div className={`grid gap-8 ${first.kind === "mobile" ? "lg:grid-cols-[1fr_0.6fr]" : "lg:grid-cols-[0.9fr_1.1fr]"} lg:items-center`}>
|
||||||
|
<PointList points={points} />
|
||||||
|
<ShotFrame shot={first} caption={t(`pages.${slug}.shot1`)} priority />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="detail-cards" className="border-y bg-card">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
|
||||||
|
<h2 id="detail-cards" className="sr-only">
|
||||||
|
{t(`pages.${slug}.title`)}
|
||||||
|
</h2>
|
||||||
|
<ul className="grid gap-4 md:grid-cols-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<li key={c.title} className="rounded-xl border bg-background p-5">
|
||||||
|
<h3 className="font-heading text-[16px] font-semibold">{c.title}</h3>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{c.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{second && (
|
||||||
|
<section aria-label={t(`pages.${slug}.shot2`)} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||||
|
<ShotFrame shot={second} caption={t(`pages.${slug}.shot2`)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CtaBand />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowRight, Bot, CalendarRange, ClipboardList, FileInput, FileSignature, FolderOpen, MapPin, Package, Receipt, Siren, Timer, WifiOff, type LucideIcon } from "lucide-react";
|
||||||
|
import { CtaBand, PageHero } from "@/components/marketing/blocks";
|
||||||
|
import { FEATURE_SLUGS, type FeatureSlug } from "@/lib/marketing/pages";
|
||||||
|
|
||||||
|
/** Übersicht aller Funktionen mit Einstieg in die Detailseiten. */
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("marketing.overview");
|
||||||
|
return { title: t("title"), description: t("lead") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detailseite je Modul — die übrigen Module stehen als Kacheln ohne eigene Seite. */
|
||||||
|
const DETAIL_ICON: Record<FeatureSlug, LucideIcon> = {
|
||||||
|
planung: CalendarRange,
|
||||||
|
baustelle: WifiOff,
|
||||||
|
zeiterfassung: Timer,
|
||||||
|
berichte: FileSignature,
|
||||||
|
abrechnung: Receipt,
|
||||||
|
lotse: Bot,
|
||||||
|
};
|
||||||
|
const DETAIL_FEATURE: Record<FeatureSlug, string> = {
|
||||||
|
planung: "board",
|
||||||
|
baustelle: "offline",
|
||||||
|
zeiterfassung: "time",
|
||||||
|
berichte: "reports",
|
||||||
|
abrechnung: "billing",
|
||||||
|
lotse: "lotse",
|
||||||
|
};
|
||||||
|
const MORE: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "orders", icon: ClipboardList },
|
||||||
|
{ key: "live", icon: MapPin },
|
||||||
|
{ key: "material", icon: Package },
|
||||||
|
{ key: "emergency", icon: Siren },
|
||||||
|
{ key: "documents", icon: FolderOpen },
|
||||||
|
{ key: "imports", icon: FileInput },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function FeaturesOverviewPage() {
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero title={t("overview.title")} lead={t("overview.lead")} />
|
||||||
|
|
||||||
|
<section aria-label={t("overview.title")} className="mx-auto max-w-6xl px-4 pb-4 sm:px-6">
|
||||||
|
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{FEATURE_SLUGS.map((slug) => {
|
||||||
|
const Icon = DETAIL_ICON[slug];
|
||||||
|
return (
|
||||||
|
<li key={slug} className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-3 font-heading text-[17px] font-semibold">{t(`pages.${slug}.title`)}</h2>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`pages.${slug}.lead`)}</p>
|
||||||
|
<Link href={`/funktionen/${slug}`} className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||||
|
{t("overview.more")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="more-title" className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||||||
|
<h2 id="more-title" className="font-heading text-[22px] font-semibold">{t("features.title")}</h2>
|
||||||
|
<ul className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{MORE.map(({ key, icon: Icon }) => (
|
||||||
|
<li key={key} className="rounded-xl border bg-card p-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`features.${key}.title`)}</h3>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`features.${key}.text`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4 text-[13px] text-muted-foreground">
|
||||||
|
{FEATURE_SLUGS.map((slug) => t(`features.${DETAIL_FEATURE[slug]}.title`)).join(" · ")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CtaBand />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { IMPRINT } from "@/lib/marketing/imprint";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("marketing.imprint");
|
||||||
|
return { title: t("title"), robots: { index: true, follow: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Impressum der Produktseite — Daten aus src/lib/marketing/imprint.ts. */
|
||||||
|
export default async function ImprintPage() {
|
||||||
|
const t = await getTranslations("marketing.imprint");
|
||||||
|
const block = "mt-8 font-heading text-[17px] font-semibold";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||||
|
<h1 className="font-heading text-[30px] font-bold text-[var(--ui-primary)]">{t("title")}</h1>
|
||||||
|
<p className="mt-2 text-[14px] text-muted-foreground">{t("intro")}</p>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("provider")}</h2>
|
||||||
|
<address className="mt-2 text-[15px] leading-relaxed not-italic">
|
||||||
|
{IMPRINT.company}
|
||||||
|
<br />
|
||||||
|
{IMPRINT.street}
|
||||||
|
<br />
|
||||||
|
{IMPRINT.city}
|
||||||
|
<br />
|
||||||
|
{IMPRINT.country}
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("represented")}</h2>
|
||||||
|
<p className="mt-2 text-[15px]">{IMPRINT.representatives.join(", ")}</p>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("contact")}</h2>
|
||||||
|
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[15px]">
|
||||||
|
<dt className="text-muted-foreground">{t("phone")}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={IMPRINT.phoneHref} className="underline-offset-2 hover:underline">
|
||||||
|
{IMPRINT.phone}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("email")}</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={`mailto:${IMPRINT.email}`} className="underline-offset-2 hover:underline">
|
||||||
|
{IMPRINT.email}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("register")}</h2>
|
||||||
|
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[15px]">
|
||||||
|
<dt className="text-muted-foreground">{t("registerCourt")}</dt>
|
||||||
|
<dd>{IMPRINT.registerCourt}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("registerNumber")}</dt>
|
||||||
|
<dd>{IMPRINT.registerNumber}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("vat")}</h2>
|
||||||
|
<p className="mt-2 text-[15px]">
|
||||||
|
{t("vatText")} {IMPRINT.vatId}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("editorial")}</h2>
|
||||||
|
<p className="mt-2 text-[15px]">{IMPRINT.editorial}</p>
|
||||||
|
|
||||||
|
<h2 className={block}>{t("dispute")}</h2>
|
||||||
|
<p className="mt-2 text-[15px] leading-relaxed">{t("disputeText")}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { MarketingShell } from "@/components/marketing/shell";
|
||||||
|
|
||||||
|
/** Öffentlicher Bereich (Produktseiten) — ohne Session, siehe PUBLIC_PATHS in src/proxy.ts. */
|
||||||
|
export default function MarketingLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return <MarketingShell>{children}</MarketingShell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { MarketingHome } from "@/components/marketing/home";
|
||||||
|
import { auth } from "@/server/auth";
|
||||||
|
import { landingPath } from "@/server/app-access";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("marketing.meta");
|
||||||
|
return { title: { absolute: t("title") }, description: t("description") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Startseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m,
|
||||||
|
* Backoffice → /dashboard (ARCHITEKTUR §5). Kopf- und Fußzeile liefert `(marketing)/layout.tsx`.
|
||||||
|
*/
|
||||||
|
export default async function Home() {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user) redirect(landingPath(session));
|
||||||
|
return <MarketingHome contactEmail={process.env.TRIAL_CONTACT_EMAIL?.trim() || undefined} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowRight, CheckCircle2, Info } from "lucide-react";
|
||||||
|
import { PageHero } from "@/components/marketing/blocks";
|
||||||
|
|
||||||
|
/** Testphase und Preise — bewusst ohne erfundene Zahlen, solange das Preismodell offen ist. */
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("marketing.pricingPage");
|
||||||
|
return { title: t("title"), description: t("lead") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PricingPage() {
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
const trialPoints = (["t1", "t2", "t3"] as const).map((k) => t(`pricingPage.${k}`));
|
||||||
|
const fullPoints = (["f1", "f2", "f3"] as const).map((k) => t(`pricingPage.${k}`));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero eyebrow={t("trial.eyebrow")} title={t("pricingPage.title")} lead={t("pricingPage.lead")} />
|
||||||
|
|
||||||
|
<section aria-label={t("pricingPage.title")} className="mx-auto max-w-5xl px-4 pb-10 sm:px-6">
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
<article className="shadow-card rounded-2xl border border-l-4 border-l-[var(--ui-accent)] bg-card p-6">
|
||||||
|
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.trialTitle")}</h2>
|
||||||
|
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.trialText")}</p>
|
||||||
|
<ul className="mt-4 grid gap-2.5">
|
||||||
|
{trialPoints.map((p) => (
|
||||||
|
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href="/testen"
|
||||||
|
className="mt-5 inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
|
||||||
|
>
|
||||||
|
{t("trial.cta")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="shadow-card rounded-2xl border bg-card p-6">
|
||||||
|
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.fullTitle")}</h2>
|
||||||
|
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.fullText")}</p>
|
||||||
|
<ul className="mt-4 grid gap-2.5">
|
||||||
|
{fullPoints.map((p) => (
|
||||||
|
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-4 py-3 text-[14px]">
|
||||||
|
<Info className="mt-0.5 size-[18px] shrink-0 text-[var(--info)]" aria-hidden />
|
||||||
|
{t("pricingPage.note")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="pricing-faq" className="border-t bg-card">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6">
|
||||||
|
<h2 id="pricing-faq" className="font-heading text-[24px] font-semibold">{t("faq.title")}</h2>
|
||||||
|
<div className="mt-5 divide-y rounded-xl border bg-background">
|
||||||
|
{(["q1", "q4", "q5", "q7", "q8"] as const).map((key) => (
|
||||||
|
<details key={key} className="group px-4">
|
||||||
|
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-3 text-[15px] font-semibold">
|
||||||
|
{t(`faq.${key}.q`)}
|
||||||
|
<span aria-hidden className="text-[var(--ui-primary)] transition-transform group-open:rotate-45">+</span>
|
||||||
|
</summary>
|
||||||
|
<p className="pb-4 text-[14.5px] leading-relaxed text-muted-foreground">{t(`faq.${key}.a`)}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Download, EyeOff, KeyRound, Layers, ScrollText, UsersRound, type LucideIcon } from "lucide-react";
|
||||||
|
import { CtaBand, PageHero, ShotFrame } from "@/components/marketing/blocks";
|
||||||
|
|
||||||
|
/** Sicherheit und Datenschutz — Mandantentrennung, Rollen, Protokoll, Export. */
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("marketing.securityPage");
|
||||||
|
return { title: t("title"), description: t("lead") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEMS: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "item1", icon: Layers },
|
||||||
|
{ key: "item2", icon: UsersRound },
|
||||||
|
{ key: "item3", icon: KeyRound },
|
||||||
|
{ key: "item4", icon: ScrollText },
|
||||||
|
{ key: "item5", icon: Download },
|
||||||
|
{ key: "item6", icon: EyeOff },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function SecurityPage() {
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHero eyebrow={t("security.eyebrow")} title={t("securityPage.title")} lead={t("securityPage.lead")} />
|
||||||
|
|
||||||
|
<section aria-label={t("securityPage.title")} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{ITEMS.map(({ key, icon: Icon }) => (
|
||||||
|
<li key={key} className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-3 font-heading text-[16px] font-semibold">{t(`security.${key}.title`)}</h2>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`security.${key}.text`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="security-detail" className="border-y bg-card">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<h2 id="security-detail" className="font-heading text-[24px] font-semibold">{t("securityPage.detailTitle")}</h2>
|
||||||
|
<dl className="mt-5 grid gap-4">
|
||||||
|
{(["d1", "d2", "d3"] as const).map((k) => (
|
||||||
|
<div key={k}>
|
||||||
|
<dt className="font-heading text-[16px] font-semibold">{t(`securityPage.${k}title`)}</dt>
|
||||||
|
<dd className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{t(`securityPage.${k}text`)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<ShotFrame shot={{ src: "audit", kind: "desktop" }} caption={t("securityPage.shot1")} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label={t("securityPage.shot2")} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||||
|
<ShotFrame shot={{ src: "teams", kind: "desktop" }} caption={t("securityPage.shot2")} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<CtaBand />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
import { MODULES } from "@/lib/modules";
|
import { MODULES } from "@/lib/modules";
|
||||||
|
import { effectiveTier, isModuleInTier } from "@/lib/plans";
|
||||||
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
|
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
|
||||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||||
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
|
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
|
||||||
@@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms";
|
|||||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||||
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
||||||
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
|
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
|
||||||
|
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
|
||||||
|
|
||||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ export default async function AdminTenantPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
|
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
|
||||||
}) {
|
}) {
|
||||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -58,6 +60,9 @@ export default async function AdminTenantPage({
|
|||||||
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
|
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
|
||||||
const base = `/admin/${tenant.id}`;
|
const base = `/admin/${tenant.id}`;
|
||||||
|
|
||||||
|
// L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
|
||||||
|
const tierNow = effectiveTier(tenant.tier, tenant.plan);
|
||||||
|
const tp = await getTranslations("plans");
|
||||||
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
|
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
|
||||||
const isOn = (key: string) => moduleState.get(key) ?? true;
|
const isOn = (key: string) => moduleState.get(key) ?? true;
|
||||||
|
|
||||||
@@ -226,6 +231,11 @@ export default async function AdminTenantPage({
|
|||||||
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
|
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
|
||||||
|
{platformSession?.user?.id && (
|
||||||
|
<PlanAdminCard tenantId={tenant.id} platformAdminId={platformSession.user.id} isFullAdmin={isFullAdmin} base={base} edit={sp.plan === "edit"} done={!!sp.planDone} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Lebenszyklus */}
|
{/* Lebenszyklus */}
|
||||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
|
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
|
||||||
@@ -342,6 +352,7 @@ export default async function AdminTenantPage({
|
|||||||
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
|
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!isModuleInTier(tierNow, m.key) && <Pill tone="warn">{tp("notInTier")}</Pill>}
|
||||||
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
|
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
|
||||||
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
||||||
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
|
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
// L15 Testphase: Filter Test/Voll
|
// L15 Testphase: Filter Test/Voll
|
||||||
const tt = await getTranslations("trial.platform");
|
const tt = await getTranslations("trial.platform");
|
||||||
|
const tp = await getTranslations("plans");
|
||||||
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
|
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
|
||||||
|
|
||||||
const [tenants, mailStatus] = await Promise.all([
|
const [tenants, mailStatus] = await Promise.all([
|
||||||
@@ -124,6 +125,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
|||||||
<TableHead>Kürzel</TableHead>
|
<TableHead>Kürzel</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>{tt("colPlan")}</TableHead>
|
<TableHead>{tt("colPlan")}</TableHead>
|
||||||
|
<TableHead>{tp("colTier")}</TableHead>
|
||||||
<TableHead>Nutzer</TableHead>
|
<TableHead>Nutzer</TableHead>
|
||||||
<TableHead>Aktive Module</TableHead>
|
<TableHead>Aktive Module</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -140,6 +142,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
|||||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||||
<TableCell><TrialBadge tenant={t} /></TableCell>
|
<TableCell><TrialBadge tenant={t} /></TableCell>
|
||||||
|
{/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */}
|
||||||
|
<TableCell><Pill tone={t.tier === "PROFI" ? "ok" : "mut"}>{tp(`tier.${t.tier}`)}</Pill></TableCell>
|
||||||
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { auth } from "@/server/auth";
|
|
||||||
import { landingPath } from "@/server/app-access";
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
// Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5)
|
|
||||||
redirect(landingPath(await auth()));
|
|
||||||
}
|
|
||||||
@@ -89,6 +89,7 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
ai_generation: "Lotse (KI)",
|
ai_generation: "Lotse (KI)",
|
||||||
lotse_settings: "Lotse-Einstellungen",
|
lotse_settings: "Lotse-Einstellungen",
|
||||||
lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16
|
lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16
|
||||||
|
lotse_chat_seat: "Lotse-Chat-Platz", tenant_plan: "Paket (Basis/Profi)", // L17
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ const ITEMS = [
|
|||||||
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
|
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
|
||||||
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
|
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
|
||||||
*/
|
*/
|
||||||
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) {
|
const GRID_COLS: Record<number, string> = { 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6" };
|
||||||
|
|
||||||
|
/** L17 Pakete: `emergency` = Notdienst im Paket enthalten und eingeschaltet; `lotse` = Chat nutzbar (inkl. Platz). */
|
||||||
|
export function BottomNav({ approvals = 0, lotse = false, emergency = true }: { approvals?: number; lotse?: boolean; emergency?: boolean }) {
|
||||||
const t = useTranslations("field.nav");
|
const t = useTranslations("field.nav");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const items = ITEMS.filter((item) => (lotse || item.key !== "lotse") && (emergency || item.key !== "emergency"));
|
||||||
return (
|
return (
|
||||||
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
|
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
|
||||||
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}>
|
<ul className={cn("mx-auto grid max-w-xl", GRID_COLS[items.length] ?? "grid-cols-5")}>
|
||||||
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => {
|
{items.map((item) => {
|
||||||
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
|
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function LotseChat({
|
|||||||
transcription,
|
transcription,
|
||||||
locale,
|
locale,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
quotaExhausted = false,
|
||||||
}: {
|
}: {
|
||||||
initial: ChatView;
|
initial: ChatView;
|
||||||
workOrderId: string | null;
|
workOrderId: string | null;
|
||||||
@@ -48,6 +49,8 @@ export function LotseChat({
|
|||||||
transcription: boolean;
|
transcription: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
|
/** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */
|
||||||
|
quotaExhausted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("lotse");
|
const t = useTranslations("lotse");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -128,6 +131,7 @@ export function LotseChat({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
|
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
|
||||||
|
const sendLocked = quotaExhausted || error === "quota_exhausted";
|
||||||
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
|
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
|
||||||
|
|
||||||
function notice(m: ChatMessageView): string | null {
|
function notice(m: ChatMessageView): string | null {
|
||||||
@@ -235,7 +239,7 @@ export function LotseChat({
|
|||||||
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
|
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
|
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
|
||||||
{m.content.chips.map((c) => (
|
{m.content.chips.map((c) => (
|
||||||
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}>
|
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured || sendLocked} onClick={() => send(c.value)}>
|
||||||
{c.label}
|
{c.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -270,13 +274,19 @@ export function LotseChat({
|
|||||||
{t("chat.offline")}
|
{t("chat.offline")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{errorText && (
|
{errorText && error !== "quota_exhausted" && (
|
||||||
<p className={noticeError} role="alert">
|
<p className={noticeError} role="alert">
|
||||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
{errorText}
|
{errorText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sendLocked ? (
|
||||||
|
<p className={noticeWarn} role="status">
|
||||||
|
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{t("chat.quotaExhausted")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<form
|
<form
|
||||||
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
|
className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -315,6 +325,7 @@ export function LotseChat({
|
|||||||
</div>
|
</div>
|
||||||
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
|
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { CheckCircle2, CircleDashed, Gauge, Info, Package, TriangleAlert, XCircle } from "lucide-react";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { setChatSeatAction } from "@/server/actions/lotse/seats";
|
||||||
|
import { getTenantPlan } from "@/server/plan";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { getSeatOverview } from "@/server/services/lotse/chat/seats";
|
||||||
|
import { getChatUsageForAdmin } from "@/server/services/lotse/chat/usage";
|
||||||
|
|
||||||
|
const LEVEL_TONE = { ok: "var(--ok)", warn: "var(--warn)", exhausted: "var(--warn)", over: "var(--risk)" } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — /settings/lotse (tenant:manage): package (read-only), Lotse chat seats „x von y vergeben"
|
||||||
|
* with a switch per user with a field role, monthly usage bar with text, 80 % / overage hints and the
|
||||||
|
* per-user breakdown. Every change runs through setChatSeatAction (server-side limits + audit).
|
||||||
|
*/
|
||||||
|
export async function LotseSeatAdmin({ ctx, seatError, seatSaved }: { ctx: ServiceCtx; seatError?: string; seatSaved?: boolean }) {
|
||||||
|
const [t, locale, plan] = await Promise.all([getTranslations("plans"), getLocale(), getTenantPlan(ctx.tenantId)]);
|
||||||
|
const nf = new Intl.NumberFormat(locale);
|
||||||
|
const profi = plan.tier === "PROFI";
|
||||||
|
const [seats, usage] = profi ? await Promise.all([getSeatOverview(ctx), getChatUsageForAdmin(ctx)]) : [null, null];
|
||||||
|
const free = seats ? Math.max(0, seats.purchased - seats.assigned) : 0;
|
||||||
|
const monthLabel = usage ? new Date(`${usage.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" }) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="seats" aria-labelledby="seats-title" className="shadow-card mt-5 max-w-5xl space-y-5 rounded-xl border bg-card p-5 text-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 id="seats-title" className="flex items-center gap-2 font-heading text-sm font-semibold">
|
||||||
|
<Package className="size-4" aria-hidden /> {t("admin.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("admin.sub")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[12.5px] text-muted-foreground">{t("admin.package")}</span>
|
||||||
|
<Pill tone={profi ? "ok" : "mut"}>{t(`tier.${plan.tier}`)}</Pill>
|
||||||
|
{plan.isTrial && <Pill tone="warn">{t("admin.trial")}</Pill>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("admin.packageReadOnly")}</p>
|
||||||
|
|
||||||
|
{seatSaved && (
|
||||||
|
<p role="status" className="flex items-center gap-2 rounded-lg border border-[var(--ok)] px-3 py-2 text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden /> {t("admin.saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{seatError && (
|
||||||
|
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4" aria-hidden /> {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!profi || !seats || !usage ? (
|
||||||
|
<p className="flex items-start gap-2 rounded-lg bg-muted p-3">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.basisHint")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Plätze */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold">{t("admin.seatsTitle")}</h3>
|
||||||
|
{plan.isTrial ? (
|
||||||
|
<p className="flex items-start gap-2 text-muted-foreground">
|
||||||
|
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[15px] font-semibold">{t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}</p>
|
||||||
|
)}
|
||||||
|
{seats.overbooked && (
|
||||||
|
<p className="flex items-start gap-2 text-[var(--warn)]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overbooked", { purchased: seats.purchased })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!plan.isTrial && seats.purchased === 0 && <p className="text-muted-foreground">{t("admin.noSeatsBought")}</p>}
|
||||||
|
{seats.users.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("admin.noFieldUsers")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-lg border">
|
||||||
|
{seats.users.map((u) => {
|
||||||
|
const canAssign = !u.assigned && free > 0;
|
||||||
|
return (
|
||||||
|
<li key={u.id} className="flex flex-wrap items-center justify-between gap-3 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{u.name}</p>
|
||||||
|
<p className="truncate text-[12px] text-muted-foreground">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={cn("inline-flex items-center gap-1 text-[12.5px] font-semibold", !u.assigned && "text-muted-foreground")} style={u.assigned ? { color: u.valid ? "var(--ok)" : "var(--warn)" } : undefined}>
|
||||||
|
{u.assigned ? (u.valid ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />) : <CircleDashed className="size-4" aria-hidden />}
|
||||||
|
{u.assigned ? (u.valid ? t("admin.seatAssigned") : t("admin.seatOverbooked")) : t("admin.seatNone")}
|
||||||
|
</span>
|
||||||
|
<form action={setChatSeatAction}>
|
||||||
|
<input type="hidden" name="userId" value={u.id} />
|
||||||
|
<input type="hidden" name="assigned" value={u.assigned ? "0" : "1"} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={u.assigned}
|
||||||
|
aria-label={u.assigned ? t("admin.revokeLabel", { name: u.name }) : t("admin.assignLabel", { name: u.name })}
|
||||||
|
disabled={!u.assigned && !canAssign}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-11 min-w-28 items-center justify-center rounded-lg border px-3 text-[13px] font-semibold disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||||
|
u.assigned ? "border-[var(--ui-primary)] text-[var(--ui-primary)]" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{u.assigned ? t("admin.revoke") : t("admin.assign")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monatsverbrauch */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="flex items-center gap-2 font-semibold">
|
||||||
|
<Gauge className="size-4" aria-hidden /> {t("admin.usageTitle", { month: monthLabel })}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
role="meter"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={usage.percent}
|
||||||
|
aria-label={t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}
|
||||||
|
className="h-3 w-full overflow-hidden rounded-full bg-muted"
|
||||||
|
>
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${usage.percent}%`, background: LEVEL_TONE[usage.level] }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-semibold">{t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}</p>
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}</p>
|
||||||
|
{usage.level === "warn" && (
|
||||||
|
<p className="flex items-start gap-2 text-[var(--warn)]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.warn80", { percent: usage.percent })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && (
|
||||||
|
<p className="flex items-start gap-2 text-[var(--risk)]">
|
||||||
|
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.hardLimitReached")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{usage.level === "exhausted" && !usage.hardLimit && (
|
||||||
|
<p className="flex items-start gap-2 text-[var(--warn)]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{usage.overage > 0 && (
|
||||||
|
<p className="flex items-start gap-2 text-[var(--risk)]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{usage.perUser.length > 0 && (
|
||||||
|
<table className="mt-2 w-full max-w-md text-left">
|
||||||
|
<caption className="sr-only">{t("admin.perUserCaption")}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[12px] text-muted-foreground">
|
||||||
|
<th scope="col" className="py-1 font-medium">{t("admin.perUserName")}</th>
|
||||||
|
<th scope="col" className="py-1 text-right font-medium">{t("admin.perUserChats")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{usage.perUser.map((u) => (
|
||||||
|
<tr key={u.userId}>
|
||||||
|
<td className="py-1.5">{u.name}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">{nf.format(u.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { CircleSlash } from "lucide-react";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
|
||||||
|
/** „Lotse-Chat nicht verfügbar" (L16; L17: kein Platz / nicht im Paket) — text + icon, back to /m. */
|
||||||
|
export async function LotseChatUnavailable({ reason }: { reason: string }) {
|
||||||
|
const t = await getTranslations("lotse");
|
||||||
|
return (
|
||||||
|
<main className="space-y-3 p-4">
|
||||||
|
<section className={card}>
|
||||||
|
<h1 className="flex items-center gap-2 text-[20px]">
|
||||||
|
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
|
||||||
|
{t("chat.unavailable.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-[15px]">{t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}</p>
|
||||||
|
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
|
||||||
|
{t("chat.unavailable.back")}
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowRight, Check } from "lucide-react";
|
||||||
|
import type { Shot } from "@/lib/marketing/pages";
|
||||||
|
|
||||||
|
/** Bausteine der öffentlichen Produktseiten. Bildschirmfotos stammen aus dem Demo-Mandanten. */
|
||||||
|
|
||||||
|
const SIZE = {
|
||||||
|
desktop: { width: 2880, height: 1800, sizes: "(min-width: 1024px) 900px, 100vw" },
|
||||||
|
mobile: { width: 1170, height: 2532, sizes: "(min-width: 1024px) 320px, 60vw" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ShotFrame({ shot, caption, priority }: { shot: Shot; caption: string; priority?: boolean }) {
|
||||||
|
const s = SIZE[shot.kind];
|
||||||
|
return (
|
||||||
|
<figure className={shot.kind === "mobile" ? "mx-auto w-full max-w-[280px]" : "w-full"}>
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Image src={`/marketing/${shot.src}.png`} alt={caption} width={s.width} height={s.height} sizes={s.sizes} priority={priority} className="h-auto w-full" />
|
||||||
|
</div>
|
||||||
|
<figcaption className="mt-2 text-[12.5px] text-muted-foreground">{caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PointList({ points }: { points: string[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="grid gap-2.5">
|
||||||
|
{points.map((p) => (
|
||||||
|
<li key={p} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<Check className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHero({ eyebrow, title, lead }: { eyebrow?: string; title: string; lead: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 pt-12 pb-8 text-center sm:px-6">
|
||||||
|
{eyebrow && <p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{eyebrow}</p>}
|
||||||
|
<h1 className="mt-2 font-heading text-[30px] leading-tight font-bold text-[var(--ui-primary)] sm:text-[40px]">{title}</h1>
|
||||||
|
<p className="mt-4 text-[16px] leading-relaxed text-muted-foreground">{lead}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function CtaBand() {
|
||||||
|
const t = await getTranslations("marketing.cta");
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="cta-title" className="border-t bg-card">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-14 text-center sm:px-6">
|
||||||
|
<h2 id="cta-title" className="font-heading text-[26px] font-semibold text-[var(--ui-primary)]">{t("title")}</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-xl text-[15.5px] text-muted-foreground">{t("text")}</p>
|
||||||
|
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/testen"
|
||||||
|
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
|
||||||
|
>
|
||||||
|
{t("primary")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link href="/funktionen" className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg border px-5 text-[15px] font-semibold hover:bg-muted">
|
||||||
|
{t("secondary")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Bot,
|
||||||
|
CalendarRange,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Download,
|
||||||
|
EyeOff,
|
||||||
|
FileInput,
|
||||||
|
FileSignature,
|
||||||
|
FolderOpen,
|
||||||
|
KeyRound,
|
||||||
|
Layers,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
Receipt,
|
||||||
|
ScrollText,
|
||||||
|
Siren,
|
||||||
|
Timer,
|
||||||
|
UsersRound,
|
||||||
|
WifiOff,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ShotFrame } from "./blocks";
|
||||||
|
import { FieldMockup } from "./mockups";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Startseite (Produktseite). Wird von `/` gerendert, solange keine Session besteht;
|
||||||
|
* angemeldete Nutzer leitet die Route auf Dashboard bzw. mobile Ansicht. Alle Texte kommen aus
|
||||||
|
* `messages/<locale>/marketing.json` — keine Screenshots, die Vorschauen sind Markup (mockups.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FEATURES: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "orders", icon: ClipboardList },
|
||||||
|
{ key: "board", icon: CalendarRange },
|
||||||
|
{ key: "live", icon: MapPin },
|
||||||
|
{ key: "time", icon: Timer },
|
||||||
|
{ key: "offline", icon: WifiOff },
|
||||||
|
{ key: "reports", icon: FileSignature },
|
||||||
|
{ key: "material", icon: Package },
|
||||||
|
{ key: "billing", icon: Receipt },
|
||||||
|
{ key: "emergency", icon: Siren },
|
||||||
|
{ key: "lotse", icon: Bot },
|
||||||
|
{ key: "documents", icon: FolderOpen },
|
||||||
|
{ key: "imports", icon: FileInput },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECURITY: { key: string; icon: LucideIcon }[] = [
|
||||||
|
{ key: "item1", icon: Layers },
|
||||||
|
{ key: "item2", icon: UsersRound },
|
||||||
|
{ key: "item3", icon: KeyRound },
|
||||||
|
{ key: "item4", icon: ScrollText },
|
||||||
|
{ key: "item5", icon: Download },
|
||||||
|
{ key: "item6", icon: EyeOff },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAQ = ["q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8"] as const;
|
||||||
|
const INDUSTRIES = ["shk", "electric", "cooling", "lifts", "windows", "service"] as const;
|
||||||
|
|
||||||
|
const ctaCls =
|
||||||
|
"inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95 focus-visible:outline-2 focus-visible:outline-offset-2";
|
||||||
|
const outlineCls =
|
||||||
|
"inline-flex min-h-12 items-center justify-center gap-2 rounded-lg border px-5 text-[15px] font-semibold hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2";
|
||||||
|
|
||||||
|
export async function MarketingHome({ contactEmail }: { contactEmail?: string }) {
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="border-b bg-card">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-12 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:py-20">
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("hero.eyebrow")}</p>
|
||||||
|
<h1 className="mt-3 font-heading text-[34px] leading-[1.1] font-bold text-[var(--ui-primary)] sm:text-[46px]">{t("hero.title")}</h1>
|
||||||
|
<p className="mt-4 max-w-xl text-[16.5px] leading-relaxed text-muted-foreground">{t("hero.subtitle")}</p>
|
||||||
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
|
<Link href="/testen" className={ctaCls}>
|
||||||
|
{t("hero.ctaPrimary")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<a href="#features" className={outlineCls}>
|
||||||
|
{t("hero.ctaSecondary")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 grid gap-2">
|
||||||
|
{(["bullet1", "bullet2", "bullet3"] as const).map((k) => (
|
||||||
|
<li key={k} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
|
||||||
|
{t(`hero.${k}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<ShotFrame shot={{ src: "plantafel", kind: "desktop" }} caption={t("pages.planung.shot1")} priority />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Leitgedanken */}
|
||||||
|
<section aria-labelledby="pillars" className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<h2 id="pillars" className="font-heading text-[26px] font-semibold">{t("pillars.title")}</h2>
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
{(["one", "two", "three"] as const).map((k) => (
|
||||||
|
<article key={k} className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<h3 className="font-heading text-[17px] font-semibold">{t(`pillars.${k}.title`)}</h3>
|
||||||
|
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t(`pillars.${k}.text`)}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Funktionen */}
|
||||||
|
<section id="features" aria-labelledby="features-title" className="scroll-mt-20 border-y bg-card">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<h2 id="features-title" className="font-heading text-[26px] font-semibold">{t("features.title")}</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-[15px] text-muted-foreground">{t("features.sub")}</p>
|
||||||
|
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{FEATURES.map(({ key, icon: Icon }) => (
|
||||||
|
<li key={key} className="rounded-xl border bg-background p-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`features.${key}.title`)}</h3>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`features.${key}.text`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Planung */}
|
||||||
|
<section id="planning" aria-labelledby="planning-title" className="scroll-mt-20">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 lg:grid-cols-2 lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("planning.eyebrow")}</p>
|
||||||
|
<h2 id="planning-title" className="mt-2 font-heading text-[26px] font-semibold">{t("planning.title")}</h2>
|
||||||
|
<p className="mt-3 text-[15px] leading-relaxed text-muted-foreground">{t("planning.text")}</p>
|
||||||
|
<ul className="mt-5 grid gap-2.5">
|
||||||
|
{(["point1", "point2", "point3", "point4"] as const).map((k) => (
|
||||||
|
<li key={k} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<Check className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||||
|
{t(`planning.${k}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<ShotFrame shot={{ src: "live-lage", kind: "desktop" }} caption={t("pages.planung.shot2")} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Baustelle */}
|
||||||
|
<section id="mobile" aria-labelledby="mobile-title" className="scroll-mt-20 border-y bg-card">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("mobile.eyebrow")}</p>
|
||||||
|
<h2 id="mobile-title" className="mt-2 font-heading text-[26px] font-semibold">{t("mobile.title")}</h2>
|
||||||
|
<p className="mt-3 text-[15px] leading-relaxed text-muted-foreground">{t("mobile.text")}</p>
|
||||||
|
<p className="mt-4 flex items-start gap-2 rounded-lg border border-[var(--info)] bg-background px-3 py-2 text-[14px]">
|
||||||
|
<WifiOff className="mt-0.5 size-[18px] shrink-0 text-[var(--info)]" aria-hidden />
|
||||||
|
{t("mobile.offlineNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FieldMockup />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Lotse */}
|
||||||
|
<section aria-labelledby="lotse-title">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-6 lg:grid-cols-2 lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("lotse.eyebrow")}</p>
|
||||||
|
<h2 id="lotse-title" className="mt-2 font-heading text-[26px] font-semibold">{t("lotse.title")}</h2>
|
||||||
|
<p className="mt-3 text-[15px] leading-relaxed text-muted-foreground">{t("lotse.text")}</p>
|
||||||
|
<p className="mt-4 text-[13.5px] text-muted-foreground">{t("lotse.note")}</p>
|
||||||
|
</div>
|
||||||
|
<ShotFrame shot={{ src: "mobil-lotse", kind: "mobile" }} caption={t("pages.lotse.shot1")} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Ablauf */}
|
||||||
|
<section aria-labelledby="steps-title" className="border-y bg-card">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<h2 id="steps-title" className="font-heading text-[26px] font-semibold">{t("steps.title")}</h2>
|
||||||
|
<ol className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
{(["one", "two", "three"] as const).map((k, i) => (
|
||||||
|
<li key={k} className="rounded-xl border bg-background p-5">
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-full bg-[var(--ui-primary)] text-[15px] font-bold text-white tabular-nums">{i + 1}</span>
|
||||||
|
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`steps.${k}.title`)}</h3>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`steps.${k}.text`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Gewerke */}
|
||||||
|
<section aria-labelledby="industries-title" className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<h2 id="industries-title" className="font-heading text-[26px] font-semibold">{t("industries.title")}</h2>
|
||||||
|
<p className="mt-2 text-[15px] text-muted-foreground">{t("industries.sub")}</p>
|
||||||
|
<ul className="mt-6 flex flex-wrap gap-2.5">
|
||||||
|
{INDUSTRIES.map((key) => (
|
||||||
|
<li key={key} className="rounded-full border bg-card px-4 py-2 text-[14px] font-medium">
|
||||||
|
{t(`industries.${key}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sicherheit */}
|
||||||
|
<section id="security" aria-labelledby="security-title" className="scroll-mt-20 border-y bg-card">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("security.eyebrow")}</p>
|
||||||
|
<h2 id="security-title" className="mt-2 font-heading text-[26px] font-semibold">{t("security.title")}</h2>
|
||||||
|
<p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-muted-foreground">{t("security.text")}</p>
|
||||||
|
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{SECURITY.map(({ key, icon: Icon }) => (
|
||||||
|
<li key={key} className="rounded-xl border bg-background p-5">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`security.${key}.title`)}</h3>
|
||||||
|
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`security.${key}.text`)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Testphase */}
|
||||||
|
<section id="pricing" aria-labelledby="trial-title" className="scroll-mt-20">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-14 sm:px-6">
|
||||||
|
<div className="shadow-card grid gap-8 rounded-2xl border bg-card p-6 sm:p-9 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold tracking-wide text-[var(--ui-accent)] uppercase">{t("trial.eyebrow")}</p>
|
||||||
|
<h2 id="trial-title" className="mt-2 font-heading text-[26px] font-semibold">{t("trial.title")}</h2>
|
||||||
|
<p className="mt-3 text-[15px] leading-relaxed text-muted-foreground">{t("trial.text")}</p>
|
||||||
|
<ul className="mt-5 grid gap-2.5">
|
||||||
|
{(["point1", "point2", "point3"] as const).map((k) => (
|
||||||
|
<li key={k} className="flex items-start gap-2 text-[14.5px]">
|
||||||
|
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
|
||||||
|
{t(`trial.${k}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Link href="/testen" className={ctaCls}>
|
||||||
|
{t("trial.cta")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
{contactEmail && (
|
||||||
|
<a href={`mailto:${contactEmail}`} className={outlineCls}>
|
||||||
|
{t("trial.secondary")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<p className="text-[13px] leading-relaxed text-muted-foreground">{t("trial.note")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fragen */}
|
||||||
|
<section id="faq" aria-labelledby="faq-title" className="scroll-mt-20 border-y bg-card">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-14 sm:px-6">
|
||||||
|
<h2 id="faq-title" className="font-heading text-[26px] font-semibold">{t("faq.title")}</h2>
|
||||||
|
<div className="mt-6 divide-y rounded-xl border bg-background">
|
||||||
|
{FAQ.map((key) => (
|
||||||
|
<details key={key} className="group px-4">
|
||||||
|
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-3 text-[15px] font-semibold">
|
||||||
|
{t(`faq.${key}.q`)}
|
||||||
|
<span aria-hidden className="text-[var(--ui-primary)] transition-transform group-open:rotate-45">+</span>
|
||||||
|
</summary>
|
||||||
|
<p className="pb-4 text-[14.5px] leading-relaxed text-muted-foreground">{t(`faq.${key}.a`)}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Abschluss */}
|
||||||
|
<section aria-labelledby="closing-title" className="mx-auto max-w-6xl px-4 py-16 text-center sm:px-6">
|
||||||
|
<h2 id="closing-title" className="font-heading text-[28px] font-semibold text-[var(--ui-primary)]">{t("closing.title")}</h2>
|
||||||
|
<p className="mx-auto mt-3 max-w-xl text-[15.5px] text-muted-foreground">{t("closing.text")}</p>
|
||||||
|
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||||||
|
<Link href="/testen" className={ctaCls}>
|
||||||
|
{t("closing.cta")}
|
||||||
|
<ArrowRight className="size-4" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className={outlineCls}>
|
||||||
|
{t("closing.login")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Check, PenLine, Truck, Wrench } from "lucide-react";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablaufdarstellung für die Startseite — bewusst als Markup statt Screenshot:
|
||||||
|
* bleibt scharf, mehrsprachig, ohne Bildassets und ohne Daten aus einem echten Mandanten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function FieldMockup() {
|
||||||
|
const t = await getTranslations("marketing.mobile");
|
||||||
|
const steps = [
|
||||||
|
{ icon: Truck, ...{ title: t("step1.title"), text: t("step1.text") } },
|
||||||
|
{ icon: Wrench, ...{ title: t("step2.title"), text: t("step2.text") } },
|
||||||
|
{ icon: PenLine, ...{ title: t("step3.title"), text: t("step3.text") } },
|
||||||
|
{ icon: Check, ...{ title: t("step4.title"), text: t("step4.text") } },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ol className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{steps.map((step, i) => {
|
||||||
|
const Icon = step.icon;
|
||||||
|
return (
|
||||||
|
<li key={step.title} className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
|
||||||
|
<Icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<p className="mt-3 text-[14.5px] font-semibold">
|
||||||
|
<span className="text-muted-foreground tabular-nums">{i + 1}. </span>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13.5px] text-muted-foreground">{step.text}</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
|
import { BRAND } from "@/lib/brand";
|
||||||
|
|
||||||
|
/** Gemeinsames Gerüst der öffentlichen Produktseiten: Kopfzeile, Inhalt, Fußzeile. */
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ key: "features", href: "/funktionen" },
|
||||||
|
{ key: "planning", href: "/funktionen/planung" },
|
||||||
|
{ key: "mobile", href: "/funktionen/baustelle" },
|
||||||
|
{ key: "security", href: "/sicherheit" },
|
||||||
|
{ key: "pricing", href: "/preise" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function MarketingShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const t = await getTranslations("marketing");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3 sm:px-6">
|
||||||
|
<Link href="/" className="flex min-h-11 items-center" aria-label={BRAND.name}>
|
||||||
|
<CraftviaLogo variant="horizontal" height={30} />
|
||||||
|
</Link>
|
||||||
|
<nav aria-label={t("nav.label")} className="ml-auto hidden lg:block">
|
||||||
|
<ul className="flex items-center gap-1">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[14px] font-medium text-muted-foreground hover:text-foreground">
|
||||||
|
{t(`nav.${item.key}`)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||||
|
<Link href="/login" className="inline-flex min-h-11 items-center px-2 text-[14px] font-semibold text-[var(--ui-primary)]">
|
||||||
|
{t("nav.login")}
|
||||||
|
</Link>
|
||||||
|
<Link href="/testen" className="inline-flex min-h-11 items-center rounded-lg bg-cta px-4 text-[14px] font-semibold text-cta-foreground hover:brightness-95">
|
||||||
|
{t("nav.cta")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
|
<footer className="border-t bg-card">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-10 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<CraftviaLogo variant="horizontal" height={28} />
|
||||||
|
<p className="mt-3 max-w-xs text-[13.5px] text-muted-foreground">{t("footer.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
<nav aria-labelledby="footer-product">
|
||||||
|
<h2 id="footer-product" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.product")}</h2>
|
||||||
|
<ul className="mt-2 grid">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
<Link href={item.href} className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">
|
||||||
|
{t(`nav.${item.key}`)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<Link href="/testen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.trial")}</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<nav aria-labelledby="footer-legal">
|
||||||
|
<h2 id="footer-legal" className="text-[13px] font-semibold tracking-wide uppercase">{t("footer.company")}</h2>
|
||||||
|
<ul className="mt-2 grid">
|
||||||
|
<li><Link href="/impressum" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.imprint")}</Link></li>
|
||||||
|
<li><Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.terms")}</Link></li>
|
||||||
|
<li><Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center text-[14px] text-muted-foreground hover:text-foreground">{t("footer.privacy")}</Link></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<p className="border-t px-4 py-4 text-center text-[12.5px] text-muted-foreground sm:px-6">
|
||||||
|
© {new Date().getFullYear()} {BRAND.name} · {BRAND.tagline} · {t("footer.rights")}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -432,14 +432,27 @@ export function PlanningBoardView({
|
|||||||
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
|
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{board.unplanned.map((o) => (
|
{board.unplanned.map((o) => (
|
||||||
<li key={o.id} className="space-y-1.5">
|
<li key={o.id}>
|
||||||
<OrderCard order={o} {...cardProps} unplanned />
|
<OrderCard
|
||||||
{canSchedule && (
|
order={o}
|
||||||
<button type="button" aria-expanded={panelFor === o.id} onClick={() => setPanelFor(panelFor === o.id ? null : o.id)} className={`${buttonCls("ghost")} w-full`}>
|
{...cardProps}
|
||||||
|
unplanned
|
||||||
|
footer={
|
||||||
|
canSchedule && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={panelFor === o.id}
|
||||||
|
aria-label={t("unplanned.suggestionsFor", { number: o.number })}
|
||||||
|
onClick={() => setPanelFor(panelFor === o.id ? null : o.id)}
|
||||||
|
className={`${buttonCls("ghost")} w-full justify-between`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Lightbulb className="size-4" aria-hidden />
|
<Lightbulb className="size-4" aria-hidden />
|
||||||
{t("unplanned.suggestions")}
|
{t("unplanned.suggestions")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn("size-4 transition-transform", panelFor === o.id && "rotate-180")} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{panelFor === o.id && (
|
{panelFor === o.id && (
|
||||||
<RecommendationsPanel
|
<RecommendationsPanel
|
||||||
workOrderId={o.id}
|
workOrderId={o.id}
|
||||||
@@ -451,6 +464,10 @@ export function PlanningBoardView({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -559,6 +576,7 @@ function OrderCard({
|
|||||||
tw,
|
tw,
|
||||||
unplanned,
|
unplanned,
|
||||||
compact,
|
compact,
|
||||||
|
footer,
|
||||||
}: {
|
}: {
|
||||||
order: BoardOrder;
|
order: BoardOrder;
|
||||||
canSchedule: boolean;
|
canSchedule: boolean;
|
||||||
@@ -569,6 +587,8 @@ function OrderCard({
|
|||||||
tw: ReturnType<typeof useTranslations<"workOrders">>;
|
tw: ReturnType<typeof useTranslations<"workOrders">>;
|
||||||
unplanned?: boolean;
|
unplanned?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** an die Karte angehängter Bereich (z. B. „Vorschläge" in der Spalte „Ungeplante Aufträge") */
|
||||||
|
footer?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status);
|
const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status);
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable });
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable });
|
||||||
@@ -678,6 +698,7 @@ function OrderCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footer && <div className="mt-1.5 border-t pt-1">{footer}</div>}
|
||||||
{movable && (
|
{movable && (
|
||||||
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
|
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
|
||||||
<CalendarPlus className="size-3.5" aria-hidden />
|
<CalendarPlus className="size-3.5" aria-hidden />
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
|
||||||
|
import { updateTenantPlanAction } from "@/server/actions/plans-platform";
|
||||||
|
import type { ChatUsage } from "@/server/services/lotse/chat/usage";
|
||||||
|
import { getTenantPlanOverview, MAX_LOTSE_CHAT_SEATS } from "@/server/services/plans/platform";
|
||||||
|
import { PlanForm } from "./plan-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — card "Paket & Lotse-Chat" on the platform tenant detail page: tier, seats assigned /
|
||||||
|
* purchased, chats of the current and previous month, overage and the resulting packs of 100 (basis for
|
||||||
|
* the invoice). Full admins change tier/seats/hard limit in a confirmation popup (?plan=edit).
|
||||||
|
*/
|
||||||
|
export async function PlanAdminCard({ tenantId, platformAdminId, isFullAdmin, base, edit, done }: { tenantId: string; platformAdminId: string; isFullAdmin: boolean; base: string; edit: boolean; done: boolean }) {
|
||||||
|
const [t, locale, overview] = await Promise.all([getTranslations("plans.platform"), getLocale(), getTenantPlanOverview({ platformAdminId }, tenantId)]);
|
||||||
|
const tt = await getTranslations("plans");
|
||||||
|
const nf = new Intl.NumberFormat(locale);
|
||||||
|
const { plan, seatsAssigned, current, previous } = overview;
|
||||||
|
const monthName = (u: ChatUsage) => new Date(`${u.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
|
||||||
|
|
||||||
|
const usageRows = [current, previous].map((u) => (
|
||||||
|
<div key={u.monthKey} className="rounded-lg border bg-muted/40 px-3 py-2" data-plan-usage={u.monthKey}>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}</p>
|
||||||
|
<p className="text-[13.5px] font-semibold">{t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}</p>
|
||||||
|
<p className="text-[12.5px]">
|
||||||
|
{u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5" data-plan-card>
|
||||||
|
<p className="mb-2 font-heading text-sm font-semibold">{t("title")}</p>
|
||||||
|
{done && <p role="status" className="mb-3 rounded-lg border border-[var(--ok)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t("saved")}</p>}
|
||||||
|
<dl className="space-y-1.5 text-[13px]">
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t("tier")}</dt>
|
||||||
|
<dd className="flex flex-wrap justify-end gap-1.5">
|
||||||
|
<Pill tone={plan.storedTier === "PROFI" ? "ok" : "mut"}>{tt(`tier.${plan.storedTier}`)}</Pill>
|
||||||
|
{plan.isTrial && <Pill tone="warn">{t("trialProfi")}</Pill>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t("seats")}</dt>
|
||||||
|
<dd>{t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<dt className="text-muted-foreground">{t("hardLimit")}</dt>
|
||||||
|
<dd>{plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}</p>
|
||||||
|
<div className="mt-3 space-y-2">{usageRows}</div>
|
||||||
|
{isFullAdmin && (
|
||||||
|
<Link href={`${base}?plan=edit`} scroll={false} className="mt-3 block">
|
||||||
|
<Button variant="outline" size="sm" className="min-h-11 w-full justify-center">
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFullAdmin && edit && (
|
||||||
|
<Modal title={t("editTitle")} closeHref={base} closeLabel={t("close")}>
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
|
<p className="text-sm">{t("editText")}</p>
|
||||||
|
<PlanForm
|
||||||
|
action={updateTenantPlanAction.bind(null, tenantId)}
|
||||||
|
tier={plan.storedTier}
|
||||||
|
seats={plan.lotseChatSeats}
|
||||||
|
hardLimit={plan.lotseChatHardLimit}
|
||||||
|
maxSeats={MAX_LOTSE_CHAT_SEATS}
|
||||||
|
closeHref={base}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
|
||||||
|
import type { PlatformPlanState } from "@/server/actions/plans-platform";
|
||||||
|
|
||||||
|
/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
|
||||||
|
export function PlanForm({
|
||||||
|
action,
|
||||||
|
tier,
|
||||||
|
seats,
|
||||||
|
hardLimit,
|
||||||
|
maxSeats,
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
action: (prev: PlatformPlanState, fd: FormData) => Promise<PlatformPlanState>;
|
||||||
|
tier: TenantTier;
|
||||||
|
seats: number;
|
||||||
|
hardLimit: boolean;
|
||||||
|
maxSeats: number;
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("plans.platform");
|
||||||
|
const tt = useTranslations("plans");
|
||||||
|
const [state, formAction, pending] = useActionState<PlatformPlanState, FormData>(action, { status: "idle" });
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="text-sm font-semibold">{t("tier")}</legend>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
|
{TENANT_TIERS.map((v) => (
|
||||||
|
<label key={v} className="flex min-h-11 cursor-pointer items-start gap-2 rounded-lg border p-3 text-sm has-[:checked]:border-[var(--ui-primary)] has-[:checked]:font-semibold">
|
||||||
|
<input type="radio" name="tier" value={v} defaultChecked={tier === v} className="mt-0.5 size-4" />
|
||||||
|
<span>
|
||||||
|
<span className="block">{tt(`tier.${v}`)}</span>
|
||||||
|
<span className="block text-[12px] font-normal text-muted-foreground">{t(`tierHint.${v}`)}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[12px] text-muted-foreground">{t("downgradeHint")}</p>
|
||||||
|
</fieldset>
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<Label htmlFor="plan-seats">{t("seatsInput")}</Label>
|
||||||
|
<Input id="plan-seats" name="lotseChatSeats" type="number" inputMode="numeric" min={0} max={maxSeats} step={1} required defaultValue={seats} className="mt-1 h-11" />
|
||||||
|
<p className="mt-1 text-[12px] text-muted-foreground">{t("seatsHint")}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex min-h-11 items-start gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="lotseChatHardLimit" defaultChecked={hardLimit} className="mt-0.5 size-5" />
|
||||||
|
<span>
|
||||||
|
<span className="block font-semibold">{t("hardLimitInput")}</span>
|
||||||
|
<span className="block text-[12px] text-muted-foreground">{t("hardLimitHint")}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-h-11 items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
|
||||||
|
</label>
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p role="alert" className="rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||||
|
{t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" className="h-11" disabled={pending}>
|
||||||
|
{pending ? t("submitting") : t("submit")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>
|
||||||
|
{t("close")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete: quiet hint "Im Paket Profi enthalten" instead of an error page — shown when a page of a
|
||||||
|
* module/feature outside the tenant's package tier was opened (redirect `/dashboard?module=profi`).
|
||||||
|
* Text + icon, colours only via tokens.
|
||||||
|
*/
|
||||||
|
export async function ProfiHint({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||||
|
const t = await getTranslations("plans");
|
||||||
|
return (
|
||||||
|
<div role="status" className={cn("mb-4 flex items-start gap-3 rounded-lg border bg-card px-4 py-3 text-sm", className)}>
|
||||||
|
<Sparkles className="mt-0.5 size-4.5 shrink-0 text-[var(--ui-primary)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{t("profiHint.title")}</p>
|
||||||
|
{!compact && <p className="text-muted-foreground">{t("profiHint.text")}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -175,6 +175,10 @@ export const LOTSE_CHAT_ERROR_CODES = [
|
|||||||
"tampered",
|
"tampered",
|
||||||
"blocked",
|
"blocked",
|
||||||
"offline",
|
"offline",
|
||||||
|
// L17 Pakete
|
||||||
|
"not_in_plan",
|
||||||
|
"no_seat",
|
||||||
|
"quota_exhausted",
|
||||||
] as const;
|
] as const;
|
||||||
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
|
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Domain-Trennung Website ↔ App: `craftvia.de` zeigt nur die Produktseiten, `app.craftvia.de` die
|
||||||
|
* Anwendung. Aktiv nur, wenn `MARKETING_HOST` gesetzt ist; die App-Domain kommt aus `AUTH_URL`.
|
||||||
|
* Ohne `MARKETING_HOST` (lokal, Tests) läuft alles wie bisher auf einem Host.
|
||||||
|
*
|
||||||
|
* Links der Produktseiten bleiben relativ (/login, /testen): der Proxy leitet sie von der
|
||||||
|
* Website-Domain auf die App-Domain um. So wird keine Domain beim Image-Bau festgeschrieben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MARKETING_PATHS = ["/", "/funktionen", "/sicherheit", "/preise", "/impressum"] as const;
|
||||||
|
|
||||||
|
export type HostSplit = { marketingHost: string; appHost: string; appOrigin: string };
|
||||||
|
|
||||||
|
export type HostDecision = { kind: "next" } | { kind: "redirect"; location: string; status: 307 | 308 };
|
||||||
|
|
||||||
|
export function hostSplit(env: Record<string, string | undefined> = process.env): HostSplit | null {
|
||||||
|
const marketingHost = env.MARKETING_HOST?.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/[/:].*$/, "");
|
||||||
|
const authUrl = env.AUTH_URL?.trim();
|
||||||
|
if (!marketingHost || !authUrl) return null;
|
||||||
|
let app: URL;
|
||||||
|
try {
|
||||||
|
app = new URL(authUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const appHost = app.hostname.toLowerCase();
|
||||||
|
if (appHost === marketingHost) return null;
|
||||||
|
return { marketingHost, appHost, appOrigin: app.origin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMarketingPath(pathname: string): boolean {
|
||||||
|
return MARKETING_PATHS.some((p) => pathname === p || (p !== "/" && pathname.startsWith(p + "/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeByHost(input: { host: string; pathname: string; search: string; hasSession: boolean }, split: HostSplit): HostDecision {
|
||||||
|
const host = input.host.toLowerCase().split(",")[0].trim().replace(/:\d+$/, "");
|
||||||
|
const target = input.pathname + input.search;
|
||||||
|
const marketingOrigin = `https://${split.marketingHost}`;
|
||||||
|
|
||||||
|
// www → Hauptdomain
|
||||||
|
if (host === `www.${split.marketingHost}`) return { kind: "redirect", location: marketingOrigin + target, status: 308 };
|
||||||
|
|
||||||
|
// Website-Domain: nur Produktseiten, alles andere (Login, Testphase, App, API) gehört zur App
|
||||||
|
if (host === split.marketingHost) {
|
||||||
|
return isMarketingPath(input.pathname) ? { kind: "next" } : { kind: "redirect", location: split.appOrigin + target, status: 308 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interne Aufrufe (Healthcheck über 127.0.0.1, Worker) bleiben unverändert
|
||||||
|
if (host !== split.appHost) return { kind: "next" };
|
||||||
|
|
||||||
|
// App-Domain: Produktseiten wohnen auf der Website, die Startseite ist der Login
|
||||||
|
if (input.pathname !== "/" && isMarketingPath(input.pathname)) return { kind: "redirect", location: marketingOrigin + target, status: 308 };
|
||||||
|
if (input.pathname === "/" && !input.hasSession) return { kind: "redirect", location: `${split.appOrigin}/login`, status: 307 };
|
||||||
|
return { kind: "next" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Anbieterkennzeichnung (§ 5 DDG) — übernommen aus https://gefim.de/impressum (Stand 21.09.2026).
|
||||||
|
* Einzige Quelle für Impressum und ggf. Mail-Fußzeilen; Beschriftungen stehen im Message-Katalog.
|
||||||
|
*/
|
||||||
|
export const IMPRINT = {
|
||||||
|
company: "GEFIM UG (haftungsbeschränkt)",
|
||||||
|
street: "Neckarstraße 7",
|
||||||
|
city: "38120 Braunschweig",
|
||||||
|
country: "Deutschland",
|
||||||
|
registerNumber: "211852",
|
||||||
|
registerCourt: "Braunschweig",
|
||||||
|
representatives: ["Martin Solarczek", "Marcel Martin Hofmann"],
|
||||||
|
phone: "+49 531 180 540 80",
|
||||||
|
phoneHref: "tel:+4953118054080",
|
||||||
|
email: "info@gefim.de",
|
||||||
|
vatId: "DE449952428",
|
||||||
|
/** Redaktionell verantwortlich (§ 18 Abs. 2 MStV) — wie auf gefim.de angegeben */
|
||||||
|
editorial: "GEFIM UG (haftungsbeschränkt), Neckarstraße 7, 38120 Braunschweig",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Aufbau der öffentlichen Funktionsseiten (`/funktionen/<slug>`). Texte liegen in
|
||||||
|
* `messages/<locale>/marketing.json` unter `pages.<slug>`, Bildschirmfotos in `public/marketing/`
|
||||||
|
* (erzeugt von `scripts/marketing-shots.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const FEATURE_SLUGS = ["planung", "baustelle", "zeiterfassung", "berichte", "abrechnung", "lotse"] as const;
|
||||||
|
export type FeatureSlug = (typeof FEATURE_SLUGS)[number];
|
||||||
|
|
||||||
|
export type Shot = { src: string; kind: "desktop" | "mobile" };
|
||||||
|
|
||||||
|
export type FeaturePage = {
|
||||||
|
/** Reihenfolge der Bildschirmfotos; Beschriftungen kommen aus `pages.<slug>.shot<n>` */
|
||||||
|
shots: Shot[];
|
||||||
|
points: number;
|
||||||
|
cards: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desktop = (src: string): Shot => ({ src, kind: "desktop" });
|
||||||
|
const mobile = (src: string): Shot => ({ src, kind: "mobile" });
|
||||||
|
|
||||||
|
export const FEATURE_PAGES: Record<FeatureSlug, FeaturePage> = {
|
||||||
|
planung: { shots: [desktop("plantafel"), desktop("live-lage")], points: 5, cards: 3 },
|
||||||
|
baustelle: { shots: [mobile("mobil-heute"), mobile("mobil-auftrag")], points: 5, cards: 3 },
|
||||||
|
zeiterfassung: { shots: [mobile("mobil-zeiten"), desktop("zeitfreigabe")], points: 5, cards: 3 },
|
||||||
|
berichte: { shots: [desktop("berichte"), desktop("auftrag")], points: 5, cards: 3 },
|
||||||
|
abrechnung: { shots: [desktop("abrechnung")], points: 5, cards: 3 },
|
||||||
|
lotse: { shots: [mobile("mobil-lotse")], points: 5, cards: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isFeatureSlug = (v: string): v is FeatureSlug => (FEATURE_SLUGS as readonly string[]).includes(v);
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
|
import type { PlanFeature } from "@/lib/plans";
|
||||||
import type { Permission } from "@/server/rbac";
|
import type { Permission } from "@/server/rbac";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,14 +48,16 @@ export interface NavItem {
|
|||||||
sub?: boolean;
|
sub?: boolean;
|
||||||
/** L13: active only on exactly this path (not on sub paths) */
|
/** L13: active only on exactly this path (not on sub paths) */
|
||||||
exact?: boolean;
|
exact?: boolean;
|
||||||
|
/** L17 Pakete: feature without own module that the package tier must include (planning) */
|
||||||
|
feature?: PlanFeature;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: readonly NavItem[] = [
|
export const NAV_ITEMS: readonly NavItem[] = [
|
||||||
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
|
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
|
||||||
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
|
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
|
||||||
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
|
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
|
||||||
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
|
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
|
||||||
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
|
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
|
||||||
{
|
{
|
||||||
href: "/work-orders",
|
href: "/work-orders",
|
||||||
label: "workOrders",
|
label: "workOrders",
|
||||||
@@ -81,14 +84,19 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
|
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
|
/**
|
||||||
|
* Filtert die Navigation nach aktiven Modulen und Rechten der Session. `disabledModules` = effektiv
|
||||||
|
* inaktive Module (L17: abgeschaltet ODER nicht in der Paketstufe), `lockedFeatures` = nicht enthaltene
|
||||||
|
* Paket-Features (Planung).
|
||||||
|
*/
|
||||||
export function visibleNavItems(
|
export function visibleNavItems(
|
||||||
items: readonly NavItem[],
|
items: readonly NavItem[],
|
||||||
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[] },
|
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[]; lockedFeatures?: ReadonlySet<string> },
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
return items.filter(
|
return items.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
(!item.module || !opts.disabledModules.has(item.module)) &&
|
(!item.module || !opts.disabledModules.has(item.module)) &&
|
||||||
|
(!item.feature || !opts.lockedFeatures?.has(item.feature)) &&
|
||||||
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
|
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,6 +333,9 @@ export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError
|
|||||||
case "not_found":
|
case "not_found":
|
||||||
return "notFound";
|
return "notFound";
|
||||||
case "forbidden":
|
case "forbidden":
|
||||||
|
// L17 Pakete: op of a module that is not part of the package / switched off (services/sync/apply.ts)
|
||||||
|
if (e.lastError?.message?.startsWith("not_in_plan")) return "notInPlan";
|
||||||
|
if (e.lastError?.message?.startsWith("module_disabled")) return "moduleDisabled";
|
||||||
return "forbidden";
|
return "forbidden";
|
||||||
case "blocked":
|
case "blocked":
|
||||||
return "blocked";
|
return "blocked";
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — central, client-safe rules of the two packages (pure data + functions, no I/O).
|
||||||
|
*
|
||||||
|
* - `BASIS`: everything without AI and without planning: work orders, customers, sites, teams, mobile
|
||||||
|
* app/offline, reports + signature + PDF mail, time tracking incl. approval, documents, notifications.
|
||||||
|
* - `PROFI`: additionally planning (board, live situation, capacity, recommendations, delay hints),
|
||||||
|
* billing overview (`billing`), emergency service (`emergency`), order import (`imports`) and the
|
||||||
|
* Lotse (`lotse`: report draft, completeness check, voice note summary, Lotse chat).
|
||||||
|
*
|
||||||
|
* The Lotse chat is additionally sold per user (seats): 150 chats per assigned seat and calendar month
|
||||||
|
* (tenant timezone), shared by the whole tenant. Beyond the quota usage keeps counting (overage, billed
|
||||||
|
* in packs of 100) unless the operator set a hard limit. A trial tenant is always PROFI without seat
|
||||||
|
* check; its quota is `eligible users × 150`.
|
||||||
|
*
|
||||||
|
* Server enforcement lives in src/server/plan.ts (reads Tenant.tier/plan) and is applied in
|
||||||
|
* requireModule / assertModuleEnabled / requireApiContext / sync / navigation / isLotseEnabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TENANT_TIERS = ["BASIS", "PROFI"] as const;
|
||||||
|
export type TenantTier = (typeof TENANT_TIERS)[number];
|
||||||
|
|
||||||
|
/** Default tier of new and existing tenants (migration default). */
|
||||||
|
export const DEFAULT_TENANT_TIER: TenantTier = "PROFI";
|
||||||
|
|
||||||
|
/** Modules that exist only in PROFI. Every other module is part of both tiers. */
|
||||||
|
export const PROFI_ONLY_MODULES = ["billing", "emergency", "imports", "lotse"] as const satisfies readonly ModuleKey[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Features that are no module of their own: `planning` lives inside the `work_orders` module (L13),
|
||||||
|
* `lotse_chat` inside `lotse` (L16, additionally needs a seat).
|
||||||
|
*/
|
||||||
|
export const PLAN_FEATURES = ["planning", "lotse_chat"] as const;
|
||||||
|
export type PlanFeature = (typeof PLAN_FEATURES)[number];
|
||||||
|
const PROFI_ONLY_FEATURES: readonly PlanFeature[] = ["planning", "lotse_chat"];
|
||||||
|
|
||||||
|
export const LOTSE_CHAT_CHATS_PER_SEAT = 150;
|
||||||
|
export const LOTSE_CHAT_OVERAGE_PACK = 100;
|
||||||
|
/** From this share of the quota the tenant admin sees a warning. */
|
||||||
|
export const LOTSE_CHAT_WARN_RATIO = 0.8;
|
||||||
|
|
||||||
|
export function isTenantTier(value: unknown): value is TenantTier {
|
||||||
|
return typeof value === "string" && (TENANT_TIERS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective tier: a trial always runs as PROFI (full functionality during the test). */
|
||||||
|
export function effectiveTier(tier: TenantTier | string | null | undefined, plan?: string | null): TenantTier {
|
||||||
|
if (plan === "TRIAL") return "PROFI";
|
||||||
|
return isTenantTier(tier) ? tier : DEFAULT_TENANT_TIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModuleInTier(tier: TenantTier, moduleKey: string): boolean {
|
||||||
|
if (tier === "PROFI") return true;
|
||||||
|
return !(PROFI_ONLY_MODULES as readonly string[]).includes(moduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeatureInTier(tier: TenantTier, feature: PlanFeature): boolean {
|
||||||
|
if (tier === "PROFI") return true;
|
||||||
|
return !PROFI_ONLY_FEATURES.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modules that the tier does not include (used for navigation/UI). */
|
||||||
|
export function modulesLockedByTier(tier: TenantTier): string[] {
|
||||||
|
return tier === "PROFI" ? [] : [...PROFI_ONLY_MODULES];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monthly quota of the tenant: assigned (and paid) seats × 150. */
|
||||||
|
export function chatQuota(seats: number): number {
|
||||||
|
return Math.max(0, Math.floor(seats)) * LOTSE_CHAT_CHATS_PER_SEAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chats above the quota. */
|
||||||
|
export function chatOverage(used: number, quota: number): number {
|
||||||
|
return Math.max(0, used - quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overage packs of 100 (started packs count). */
|
||||||
|
export function overagePacks(overage: number): number {
|
||||||
|
return overage <= 0 ? 0 : Math.ceil(overage / LOTSE_CHAT_OVERAGE_PACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageLevel = "ok" | "warn" | "exhausted" | "over";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Level of the monthly usage: `warn` from 80 %, `exhausted` = quota reached (not above),
|
||||||
|
* `over` = overage. Without quota: any use counts as overage.
|
||||||
|
*/
|
||||||
|
export function usageLevel(used: number, quota: number): UsageLevel {
|
||||||
|
if (quota <= 0) return used > 0 ? "over" : "ok";
|
||||||
|
if (used > quota) return "over";
|
||||||
|
if (used === quota) return "exhausted";
|
||||||
|
return used >= quota * LOTSE_CHAT_WARN_RATIO ? "warn" : "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share of the quota in percent (0–100 for the bar; overage caps at 100). */
|
||||||
|
export function usagePercent(used: number, quota: number): number {
|
||||||
|
if (quota <= 0) return used > 0 ? 100 : 0;
|
||||||
|
return Math.min(100, Math.round((used / quota) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard limit reached → no further chat messages. */
|
||||||
|
export function isQuotaBlocked(used: number, quota: number, hardLimit: boolean): boolean {
|
||||||
|
return hardLimit && used >= quota;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month key `YYYY-MM` of a day key `YYYY-MM-DD`. */
|
||||||
|
export function monthKeyOfDay(dayKey: string): string {
|
||||||
|
return dayKey.slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Previous month key of `YYYY-MM`. */
|
||||||
|
export function previousMonthKey(monthKey: string): string {
|
||||||
|
const [y, m] = monthKey.split("-").map(Number);
|
||||||
|
return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next month key of `YYYY-MM`. */
|
||||||
|
export function nextMonthKey(monthKey: string): string {
|
||||||
|
const [y, m] = monthKey.split("-").map(Number);
|
||||||
|
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { hostSplit, routeByHost } from "@/lib/marketing/hosts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route gate (Next.js 16 proxy, formerly middleware): redirects anonymous
|
* Route gate (Next.js 16 proxy, formerly middleware): redirects anonymous
|
||||||
@@ -11,7 +12,8 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
// Enumeration-Neutralität und single-use-Tokens, nicht dieses Gate.
|
// Enumeration-Neutralität und single-use-Tokens, nicht dieses Gate.
|
||||||
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
|
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
|
||||||
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
|
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
|
||||||
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
|
// Startseite ist die öffentliche Produktseite; angemeldete Nutzer leitet page.tsx weiter.
|
||||||
|
const PUBLIC_PATHS = ["/", "/funktionen", "/sicherheit", "/preise", "/impressum", "/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
|
||||||
|
|
||||||
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
|
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
|
||||||
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
|
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
|
||||||
@@ -24,6 +26,25 @@ const matchesAny = (pathname: string, paths: string[]) =>
|
|||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
const hasSessionCookie =
|
||||||
|
request.cookies.has("authjs.session-token") ||
|
||||||
|
request.cookies.has("__Secure-authjs.session-token");
|
||||||
|
|
||||||
|
// craftvia.de (Website) ↔ app.craftvia.de (App) — nur mit MARKETING_HOST, siehe src/lib/marketing/hosts.ts
|
||||||
|
const split = hostSplit();
|
||||||
|
if (split) {
|
||||||
|
const decision = routeByHost(
|
||||||
|
{
|
||||||
|
host: request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "",
|
||||||
|
pathname,
|
||||||
|
search: request.nextUrl.search,
|
||||||
|
hasSession: hasSessionCookie,
|
||||||
|
},
|
||||||
|
split,
|
||||||
|
);
|
||||||
|
if (decision.kind === "redirect") return NextResponse.redirect(decision.location, decision.status);
|
||||||
|
}
|
||||||
|
|
||||||
if (matchesAny(pathname, PUBLIC_PATHS)) {
|
if (matchesAny(pathname, PUBLIC_PATHS)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -40,10 +61,6 @@ export function proxy(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSessionCookie =
|
|
||||||
request.cookies.has("authjs.session-token") ||
|
|
||||||
request.cookies.has("__Secure-authjs.session-token");
|
|
||||||
|
|
||||||
if (!hasSessionCookie) {
|
if (!hasSessionCookie) {
|
||||||
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
|
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
|
||||||
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {
|
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function chatErrorState(err: unknown, view?: ChatView): LotseChatActionSt
|
|||||||
const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code;
|
const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code;
|
||||||
return { status: "error", code, at, ...withView };
|
return { status: "error", code, at, ...withView };
|
||||||
}
|
}
|
||||||
if (err instanceof ModuleDisabledError) return { status: "error", code: "disabled", at, ...withView };
|
if (err instanceof ModuleDisabledError) return { status: "error", code: err.reason === "not_in_plan" ? "not_in_plan" : "disabled", at, ...withView };
|
||||||
if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView };
|
if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView };
|
||||||
if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView };
|
if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView };
|
||||||
console.error("[actions/lotse/chat]", err);
|
console.error("[actions/lotse/chat]", err);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
|
import { setChatSeat } from "@/server/services/lotse/chat/seats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — /settings/lotse: assign / revoke a Lotse chat seat (tenant admin, `tenant:manage`).
|
||||||
|
* moduleGuard("lotse"): only while the Lotse is part of the package and switched on; the service checks
|
||||||
|
* eligibility, the purchased number (serialised) and writes the audit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const guard = moduleGuard("lotse");
|
||||||
|
|
||||||
|
function errorCode(err: unknown): string {
|
||||||
|
if (err instanceof ServiceError) return (err.details as { reason?: string } | undefined)?.reason ?? err.code;
|
||||||
|
if (err instanceof ModuleDisabledError) return err.reason;
|
||||||
|
if (err instanceof ForbiddenError) return "forbidden";
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form fields: userId, assigned ("1" = assign, otherwise revoke). */
|
||||||
|
export async function setChatSeatAction(fd: FormData): Promise<void> {
|
||||||
|
let target = "/settings/lotse?seat=saved#seats";
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("tenant:manage"));
|
||||||
|
await setChatSeat(ctx, { userId: String(fd.get("userId") ?? ""), assigned: fd.get("assigned") === "1" });
|
||||||
|
revalidatePath("/settings/lotse");
|
||||||
|
revalidatePath("/m", "layout");
|
||||||
|
} catch (err) {
|
||||||
|
const code = errorCode(err);
|
||||||
|
if (code === "failed") console.error("[actions/lotse/seats]", err);
|
||||||
|
target = `/settings/lotse?seatError=${encodeURIComponent(code)}#seats`;
|
||||||
|
}
|
||||||
|
redirect(target);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requirePlatformFullAdmin } from "@/server/platform-auth";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { updateTenantPlan } from "@/server/services/plans/platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — operator action on the platform tenant detail page: tier, Lotse chat seats, hard limit.
|
||||||
|
* EXEMPT from module gating; authorisation via the separate platform session (full admins only). The
|
||||||
|
* service re-checks the actor against the PlatformAdmin store and writes the platform audit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlatformPlanState = { status: "idle" } | { status: "error"; code: string };
|
||||||
|
|
||||||
|
export async function updateTenantPlanAction(tenantId: string, _prev: PlatformPlanState, fd: FormData): Promise<PlatformPlanState> {
|
||||||
|
const { admin } = await requirePlatformFullAdmin();
|
||||||
|
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
|
||||||
|
try {
|
||||||
|
await updateTenantPlan(
|
||||||
|
{ platformAdminId: admin.id },
|
||||||
|
tenantId,
|
||||||
|
{
|
||||||
|
tier: String(fd.get("tier") ?? "") as "BASIS" | "PROFI",
|
||||||
|
lotseChatSeats: String(fd.get("lotseChatSeats") ?? "").trim() || "0",
|
||||||
|
lotseChatHardLimit: fd.get("lotseChatHardLimit") === "on",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "error", code: err instanceof ServiceError ? err.message : "failed" };
|
||||||
|
}
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath(`/admin/${tenantId}`);
|
||||||
|
// the tenant shell renders navigation/module gates per request from the plan
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
redirect(`/admin/${tenantId}?planDone=1`);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
|
|||||||
import { ServiceError } from "@/server/services/context";
|
import { ServiceError } from "@/server/services/context";
|
||||||
import { ForbiddenError } from "@/server/rbac";
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
import { ModuleDisabledError } from "@/server/modules";
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { PROFI_ONLY_MESSAGE } from "@/server/plan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
|
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
|
||||||
@@ -73,7 +74,10 @@ export function toErrorResponse(err: unknown): Response {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
|
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
|
||||||
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
|
if (err instanceof ModuleDisabledError) {
|
||||||
|
// L17 Pakete: Modul nicht in der Paketstufe → Klartext für Clients
|
||||||
|
return err.reason === "not_in_plan" ? errorResponse("forbidden", "not included in plan", { reason: "not_in_plan", module: err.moduleKey, message: PROFI_ONLY_MESSAGE }) : errorResponse("forbidden", "module disabled");
|
||||||
|
}
|
||||||
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
|
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
|
||||||
console.error("[api] unhandled error", err);
|
console.error("[api] unhandled error", err);
|
||||||
return errorResponse("internal", "internal error");
|
return errorResponse("internal", "internal error");
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export const TENANT_MODELS: readonly string[] = [
|
|||||||
"LotseActionProposal",
|
"LotseActionProposal",
|
||||||
// L15 Testphase: Datenexport des Mandanten
|
// L15 Testphase: Datenexport des Mandanten
|
||||||
"TenantExport",
|
"TenantExport",
|
||||||
|
// L17 Pakete: vergebene Lotse-Chat-Plätze
|
||||||
|
"LotseChatSeat",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"LotseActionProposal",
|
"LotseActionProposal",
|
||||||
// L15 Testphase: Datenexport des Mandanten
|
// L15 Testphase: Datenexport des Mandanten
|
||||||
"TenantExport",
|
"TenantExport",
|
||||||
|
// L17 Pakete: vergebene Lotse-Chat-Plätze
|
||||||
|
"LotseChatSeat",
|
||||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
|||||||
{ model: "LotseActionProposal", field: "confirmedById" },
|
{ model: "LotseActionProposal", field: "confirmedById" },
|
||||||
// L15 Testphase: Datenexport
|
// L15 Testphase: Datenexport
|
||||||
{ model: "TenantExport", field: "requestedById" },
|
{ model: "TenantExport", field: "requestedById" },
|
||||||
|
// L17 Pakete: Lotse-Chat-Platz (Inhaber + vergebende Person)
|
||||||
|
{ model: "LotseChatSeat", field: "userId" },
|
||||||
|
{ model: "LotseChatSeat", field: "assignedById" },
|
||||||
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
|
||||||
// tenant business data under data processing — not part of the employee subject export.
|
// tenant business data under data processing — not part of the employee subject export.
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { moduleState, PROFI_HINT_PATH, PROFI_ONLY_MESSAGE } from "@/server/plan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
|
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
|
||||||
@@ -12,17 +12,21 @@ import { writeAuditLog } from "@/server/audit";
|
|||||||
*/
|
*/
|
||||||
export async function requireModule(moduleKey: string) {
|
export async function requireModule(moduleKey: string) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const row = await dbForTenant(session.user.tenantId).tenantModule.findUnique({
|
// L17 Pakete: Stufe + TenantModule zentral (src/server/plan.ts); nicht im Paket → ruhiger Hinweis
|
||||||
where: { tenantId_moduleKey: { tenantId: session.user.tenantId, moduleKey } },
|
const state = await moduleState(session.user.tenantId, moduleKey);
|
||||||
});
|
if (state === "not_in_plan") redirect(PROFI_HINT_PATH);
|
||||||
if (row && !row.enabled) redirect("/dashboard?module=disabled");
|
if (state === "disabled") redirect("/dashboard?module=disabled");
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
|
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
|
||||||
export class ModuleDisabledError extends Error {
|
export class ModuleDisabledError extends Error {
|
||||||
constructor(public readonly moduleKey: string) {
|
constructor(
|
||||||
super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
|
public readonly moduleKey: string,
|
||||||
|
/** L17 Pakete: `not_in_plan` = Modul gehört nicht zur Paketstufe des Mandanten (Basis). */
|
||||||
|
public readonly reason: "disabled" | "not_in_plan" = "disabled",
|
||||||
|
) {
|
||||||
|
super(reason === "not_in_plan" ? `Modul „${moduleKey}": ${PROFI_ONLY_MESSAGE}` : `Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
|
||||||
this.name = "ModuleDisabledError";
|
this.name = "ModuleDisabledError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,17 +41,17 @@ export class ModuleDisabledError extends Error {
|
|||||||
*/
|
*/
|
||||||
export async function assertModuleEnabled(session: Session, moduleKey: string) {
|
export async function assertModuleEnabled(session: Session, moduleKey: string) {
|
||||||
const tenantId = session.user.tenantId;
|
const tenantId = session.user.tenantId;
|
||||||
const row = await dbForTenant(tenantId).tenantModule.findUnique({
|
// L17 Pakete: effektive Freischaltung = Stufe erlaubt UND TenantModule aktiv (src/server/plan.ts)
|
||||||
where: { tenantId_moduleKey: { tenantId, moduleKey } },
|
const state = await moduleState(tenantId, moduleKey);
|
||||||
});
|
if (state !== "enabled") {
|
||||||
if (row && !row.enabled) {
|
|
||||||
await writeAuditLog({
|
await writeAuditLog({
|
||||||
tenantId,
|
tenantId,
|
||||||
actorId: session.user.id,
|
actorId: session.user.id,
|
||||||
action: "denied",
|
action: "denied",
|
||||||
entity: "module",
|
entity: "module",
|
||||||
entityId: moduleKey,
|
entityId: moduleKey,
|
||||||
|
...(state === "not_in_plan" ? { after: { reason: "not_in_plan" } } : {}),
|
||||||
});
|
});
|
||||||
throw new ModuleDisabledError(moduleKey);
|
throw new ModuleDisabledError(moduleKey, state === "not_in_plan" ? "not_in_plan" : "disabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { MODULE_KEYS } from "@/lib/modules";
|
||||||
|
import { effectiveTier, isFeatureInTier, isModuleInTier, PLAN_FEATURES, type PlanFeature, type TenantTier } from "@/lib/plans";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — the ONE server-side place that combines the package tier (Tenant.tier, trial = PROFI)
|
||||||
|
* with the tenant module switches (TenantModule). Effective module = tier includes it AND the module
|
||||||
|
* is switched on (missing TenantModule row = on). Every gate uses these functions:
|
||||||
|
* - requireModule / assertModuleEnabled (src/server/modules.ts) → layouts, moduleGuard, requireApiContext
|
||||||
|
* - sync apply (services/sync/apply.ts, ops of a locked module → rejected with plain text)
|
||||||
|
* - navigation (app layout, mobile shell), isLotseEnabled, planningAccess.
|
||||||
|
* `Tenant` is a platform table (no tenant_id/RLS) and is read with the owner client by id; only the
|
||||||
|
* operator changes tier/seats (services/plans/platform.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TenantPlanInfo = {
|
||||||
|
/** Effective tier (trial → PROFI). */
|
||||||
|
tier: TenantTier;
|
||||||
|
/** Stored tier (what the operator set). */
|
||||||
|
storedTier: TenantTier;
|
||||||
|
isTrial: boolean;
|
||||||
|
lotseChatSeats: number;
|
||||||
|
lotseChatHardLimit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PLAN_SELECT = { tier: true, plan: true, lotseChatSeats: true, lotseChatHardLimit: true } as const;
|
||||||
|
|
||||||
|
export function toPlanInfo(row: { tier: TenantTier; plan: string; lotseChatSeats: number; lotseChatHardLimit: boolean } | null): TenantPlanInfo {
|
||||||
|
if (!row) return { tier: "PROFI", storedTier: "PROFI", isTrial: false, lotseChatSeats: 0, lotseChatHardLimit: false };
|
||||||
|
return {
|
||||||
|
tier: effectiveTier(row.tier, row.plan),
|
||||||
|
storedTier: row.tier,
|
||||||
|
isTrial: row.plan === "TRIAL",
|
||||||
|
lotseChatSeats: row.lotseChatSeats,
|
||||||
|
lotseChatHardLimit: row.lotseChatHardLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenantPlan(tenantId: string): Promise<TenantPlanInfo> {
|
||||||
|
return toPlanInfo(await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-language hint for API clients / sync results (UI texts come from messages `plans.*`). */
|
||||||
|
export const PROFI_ONLY_MESSAGE = "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet.";
|
||||||
|
|
||||||
|
export type ModuleState = "enabled" | "disabled" | "not_in_plan";
|
||||||
|
|
||||||
|
/** Effective state of one module: tier first, then the tenant switch. */
|
||||||
|
export async function moduleState(tenantId: string, moduleKey: string): Promise<ModuleState> {
|
||||||
|
const plan = await getTenantPlan(tenantId);
|
||||||
|
if (!isModuleInTier(plan.tier, moduleKey)) return "not_in_plan";
|
||||||
|
const row = await dbForTenant(tenantId).tenantModule.findUnique({
|
||||||
|
where: { tenantId_moduleKey: { tenantId, moduleKey } },
|
||||||
|
select: { enabled: true },
|
||||||
|
});
|
||||||
|
return row && !row.enabled ? "disabled" : "enabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isModuleActive(tenantId: string, moduleKey: string): Promise<boolean> {
|
||||||
|
return (await moduleState(tenantId, moduleKey)) === "enabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EffectiveModules = {
|
||||||
|
tier: TenantTier;
|
||||||
|
isTrial: boolean;
|
||||||
|
/** Modules switched off by the tenant/operator OR not included in the tier. */
|
||||||
|
inactive: Set<string>;
|
||||||
|
/** Subset of `inactive`: locked only by the tier ("Im Paket Profi enthalten"). */
|
||||||
|
notInPlan: Set<string>;
|
||||||
|
/** Features (planning, lotse_chat) not included in the tier. */
|
||||||
|
lockedFeatures: Set<PlanFeature>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All module states in two queries (navigation, settings overview). */
|
||||||
|
export async function effectiveModules(tenantId: string): Promise<EffectiveModules> {
|
||||||
|
const [plan, rows] = await Promise.all([getTenantPlan(tenantId), dbForTenant(tenantId).tenantModule.findMany({ select: { moduleKey: true, enabled: true } })]);
|
||||||
|
const notInPlan = new Set(MODULE_KEYS.filter((k) => !isModuleInTier(plan.tier, k)));
|
||||||
|
const inactive = new Set<string>([...notInPlan, ...rows.filter((r) => !r.enabled).map((r) => r.moduleKey)]);
|
||||||
|
const lockedFeatures = new Set(PLAN_FEATURES.filter((f) => !isFeatureInTier(plan.tier, f)));
|
||||||
|
return { tier: plan.tier, isTrial: plan.isTrial, inactive, notInPlan, lockedFeatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isPlanFeatureAvailable(tenantId: string, feature: PlanFeature): Promise<boolean> {
|
||||||
|
return isFeatureInTier((await getTenantPlan(tenantId)).tier, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `forbidden` with reason `not_in_plan` — callers that hide sections on `forbidden` keep working. */
|
||||||
|
export class PlanFeatureError extends ServiceError {
|
||||||
|
constructor(public readonly feature: string) {
|
||||||
|
super("forbidden", "not_in_plan", { reason: "not_in_plan", feature, message: PROFI_ONLY_MESSAGE });
|
||||||
|
this.name = "PlanFeatureError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotInPlanError(err: unknown): boolean {
|
||||||
|
return err instanceof ServiceError && err.message === "not_in_plan";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
|
||||||
|
if (!(await isPlanFeatureAvailable(tenantId, feature))) throw new PlanFeatureError(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard hint target for pages of a locked module/feature (quiet hint instead of an error page). */
|
||||||
|
export const PROFI_HINT_PATH = "/dashboard?module=profi";
|
||||||
|
|
||||||
|
/** Layout gate for a feature without own module (planning). */
|
||||||
|
export async function requirePlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
|
||||||
|
if (!(await isPlanFeatureAvailable(tenantId, feature))) redirect(PROFI_HINT_PATH);
|
||||||
|
}
|
||||||
@@ -1,27 +1,62 @@
|
|||||||
|
import { isFeatureInTier } from "@/lib/plans";
|
||||||
|
import { getTenantPlan } from "@/server/plan";
|
||||||
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
import { isLotseEnabled } from "../settings";
|
import { isLotseEnabled } from "../settings";
|
||||||
|
import { hasValidChatSeat } from "./seats";
|
||||||
|
import { getChatUsage } from "./usage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate of the Lotse chat for technicians (lane L16): `lotse:use` + `field:execute`, Lotse module on
|
* Gate of the Lotse chat for technicians (lane L16, extended by L17 Pakete) — the ONE place for:
|
||||||
* (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure" (TenantSettings.lotseChatEnabled,
|
* - permissions `lotse:use` + `field:execute`,
|
||||||
* default on). Switched off → `blocked` (reason disabled / chat_disabled).
|
* - package tier PROFI (Basis → `blocked not_in_plan`; trial = PROFI),
|
||||||
|
* - Lotse module on (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure",
|
||||||
|
* - a chat seat of the user (`blocked no_seat`; trial: no seat check),
|
||||||
|
* - when SENDING: the monthly quota with hard limit (`blocked quota_exhausted`; without hard limit the
|
||||||
|
* usage keeps counting as overage). The token budget (budget.ts) stays as a separate safety net.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type LotseChatBlockReason = "not_in_plan" | "disabled" | "chat_disabled" | "no_seat";
|
||||||
|
|
||||||
export async function isLotseChatEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
export async function isLotseChatEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
||||||
if (!(await isLotseEnabled(ctx))) return false;
|
if (!(await isLotseEnabled(ctx))) return false;
|
||||||
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
|
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
|
||||||
return s?.lotseChatEnabled ?? true;
|
return s?.lotseChatEnabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI convenience: may the user open the chat at all (permissions + switches)? Never a security check. */
|
/** Tenant- and user-level gate without permissions and quota; null = open. */
|
||||||
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> {
|
export async function lotseChatBlockReason(ctx: ServiceCtx): Promise<LotseChatBlockReason | null> {
|
||||||
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await isLotseChatEnabled(ctx));
|
const plan = await getTenantPlan(ctx.tenantId);
|
||||||
|
if (!isFeatureInTier(plan.tier, "lotse_chat")) return "not_in_plan";
|
||||||
|
if (!(await isLotseEnabled(ctx))) return "disabled";
|
||||||
|
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
|
||||||
|
if (s && !s.lotseChatEnabled) return "chat_disabled";
|
||||||
|
if (!plan.isTrial && !(await hasValidChatSeat(ctx, ctx.userId, plan.lotseChatSeats))) return "no_seat";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertLotseChatUsable(ctx: ServiceCtx): Promise<void> {
|
/** UI convenience: may the user open the chat at all (permissions + switches + seat)? Never a security check. */
|
||||||
|
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> {
|
||||||
|
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await lotseChatBlockReason(ctx)) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCK_MESSAGES: Record<LotseChatBlockReason, string> = {
|
||||||
|
not_in_plan: "lotse chat not included in plan",
|
||||||
|
disabled: "lotse disabled for tenant",
|
||||||
|
chat_disabled: "lotse chat disabled for tenant",
|
||||||
|
no_seat: "no lotse chat seat",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hard limit reached for the current month (tenant timezone)? */
|
||||||
|
export async function isChatQuotaBlocked(ctx: ServiceCtx, now: Date = new Date()): Promise<boolean> {
|
||||||
|
return (await getChatUsage(ctx, { now, perUser: false })).blocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertLotseChatUsable(ctx: ServiceCtx, opts: { send?: boolean; now?: Date } = {}): Promise<void> {
|
||||||
assertCan(ctx, "lotse:use");
|
assertCan(ctx, "lotse:use");
|
||||||
assertCan(ctx, "field:execute");
|
assertCan(ctx, "field:execute");
|
||||||
if (!(await isLotseEnabled(ctx))) throw new ServiceError("blocked", "lotse disabled for tenant", { reason: "disabled" });
|
const reason = await lotseChatBlockReason(ctx);
|
||||||
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
|
if (reason) throw new ServiceError("blocked", BLOCK_MESSAGES[reason], { reason });
|
||||||
if (s && !s.lotseChatEnabled) throw new ServiceError("blocked", "lotse chat disabled for tenant", { reason: "chat_disabled" });
|
if (opts.send && (await isChatQuotaBlocked(ctx, opts.now))) {
|
||||||
|
throw new ServiceError("blocked", "lotse chat quota exhausted", { reason: "quota_exhausted" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ async function historyTurns(ctx: ServiceCtx, conversationId: string, env: Minimi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise<ChatView> {
|
export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise<ChatView> {
|
||||||
await assertLotseChatUsable(ctx);
|
|
||||||
const input = sendMessageSchema.parse(raw);
|
|
||||||
const now = (deps.now ?? (() => new Date()))();
|
const now = (deps.now ?? (() => new Date()))();
|
||||||
|
// L17 Pakete: seat + monthly quota (hard limit → blocked quota_exhausted) before anything is stored
|
||||||
|
await assertLotseChatUsable(ctx, { send: true, now });
|
||||||
|
const input = sendMessageSchema.parse(raw);
|
||||||
const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!;
|
const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!;
|
||||||
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
|
||||||
await assertTokenBudget(ctx, now);
|
await assertTokenBudget(ctx, now);
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { getTenantPlan } from "@/server/plan";
|
||||||
|
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — Lotse chat seats. The operator sets the number of purchased seats per tenant
|
||||||
|
* (Tenant.lotseChatSeats, platform only); the tenant admin (`tenant:manage`) assigns them to users with
|
||||||
|
* a field role (`lotse:use` + `field:execute`). Assigning beyond the purchased number → `invalid`.
|
||||||
|
*
|
||||||
|
* If the operator reduces the number below the assigned seats, the rows stay (nothing is deleted),
|
||||||
|
* but only the OLDEST `lotseChatSeats` assignments are valid (createdAt, id) — the admin sees the
|
||||||
|
* overbooking and revokes seats. Switching the tenant to Basis keeps all rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SeatCtx = Pick<ServiceCtx, "db" | "tenantId">;
|
||||||
|
|
||||||
|
const ELIGIBLE_PERMISSIONS = ["lotse:use", "field:execute"] as const;
|
||||||
|
|
||||||
|
/** Where clause: active members whose roles grant BOTH chat permissions (DB-authoritative). */
|
||||||
|
export function eligibleUserWhere() {
|
||||||
|
return {
|
||||||
|
status: "ACTIVE" as const,
|
||||||
|
AND: ELIGIBLE_PERMISSIONS.map((key) => ({ userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEligibleUsers(ctx: SeatCtx): Promise<{ id: string; name: string; email: string }[]> {
|
||||||
|
return ctx.db.user.findMany({ where: eligibleUserWhere(), select: { id: true, name: true, email: true }, orderBy: [{ name: "asc" }, { id: "asc" }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countEligibleUsers(ctx: SeatCtx): Promise<number> {
|
||||||
|
return ctx.db.user.count({ where: eligibleUserWhere() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User ids holding a VALID seat (the oldest `purchased` assignments). */
|
||||||
|
export async function validSeatUserIds(ctx: SeatCtx, purchased: number): Promise<Set<string>> {
|
||||||
|
if (purchased <= 0) return new Set();
|
||||||
|
const rows = await ctx.db.lotseChatSeat.findMany({ orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: purchased, select: { userId: true } });
|
||||||
|
return new Set(rows.map((r) => r.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasValidChatSeat(ctx: SeatCtx, userId: string, purchased: number): Promise<boolean> {
|
||||||
|
return (await validSeatUserIds(ctx, purchased)).has(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assigned seats that count (capped at the purchased number) — basis of the monthly quota. */
|
||||||
|
export async function countAssignedSeats(ctx: SeatCtx, purchased: number): Promise<{ assigned: number; counted: number }> {
|
||||||
|
const assigned = await ctx.db.lotseChatSeat.count();
|
||||||
|
return { assigned, counted: Math.min(assigned, Math.max(0, purchased)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeatOverview = {
|
||||||
|
purchased: number;
|
||||||
|
assigned: number;
|
||||||
|
/** more rows than purchased seats (operator reduced the number) */
|
||||||
|
overbooked: boolean;
|
||||||
|
users: { id: string; name: string; email: string; assigned: boolean; valid: boolean; assignedAt: string | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Admin view: purchased/assigned seats + all users with a field role and their seat state. */
|
||||||
|
export async function getSeatOverview(ctx: ServiceCtx): Promise<SeatOverview> {
|
||||||
|
assertCan(ctx, "tenant:manage");
|
||||||
|
const plan = await getTenantPlan(ctx.tenantId);
|
||||||
|
const [eligible, rows, valid] = await Promise.all([
|
||||||
|
listEligibleUsers(ctx),
|
||||||
|
ctx.db.lotseChatSeat.findMany({ select: { userId: true, createdAt: true } }),
|
||||||
|
validSeatUserIds(ctx, plan.lotseChatSeats),
|
||||||
|
]);
|
||||||
|
const seatOf = new Map(rows.map((r) => [r.userId, r.createdAt]));
|
||||||
|
const users = eligible.map((u) => ({ ...u, assigned: seatOf.has(u.id), valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null }));
|
||||||
|
// seats of users who lost the field role / were deactivated still occupy a seat → listed too
|
||||||
|
const eligibleIds = new Set(eligible.map((u) => u.id));
|
||||||
|
const orphanIds = rows.map((r) => r.userId).filter((id) => !eligibleIds.has(id));
|
||||||
|
if (orphanIds.length) {
|
||||||
|
const orphans = await ctx.db.user.findMany({ where: { id: { in: orphanIds } }, select: { id: true, name: true, email: true } });
|
||||||
|
for (const u of orphans) users.push({ ...u, assigned: true, valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null });
|
||||||
|
}
|
||||||
|
return { purchased: plan.lotseChatSeats, assigned: rows.length, overbooked: rows.length > plan.lotseChatSeats, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const seatAssignmentSchema = z.object({ userId: z.string().trim().min(1).max(64), assigned: z.boolean() });
|
||||||
|
export type SeatAssignmentInput = z.input<typeof seatAssignmentSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign or revoke a seat (`tenant:manage`). Assigning: the user must be an active member with a field
|
||||||
|
* role (else `invalid not_eligible`) and a purchased seat must be free (else `invalid no_seats_left`).
|
||||||
|
* Serialised per tenant by a row lock on the tenant settings (parallel admins cannot exceed the number).
|
||||||
|
* Idempotent: assigning an assigned / revoking a free seat changes nothing. Audit before/after.
|
||||||
|
*/
|
||||||
|
export async function setChatSeat(ctx: ServiceCtx, raw: SeatAssignmentInput): Promise<{ userId: string; assigned: boolean; changed: boolean }> {
|
||||||
|
assertCan(ctx, "tenant:manage");
|
||||||
|
const input = seatAssignmentSchema.parse(raw);
|
||||||
|
const plan = await getTenantPlan(ctx.tenantId);
|
||||||
|
if (input.assigned && plan.tier !== "PROFI") throw new ServiceError("blocked", "not_in_plan", { reason: "not_in_plan" });
|
||||||
|
|
||||||
|
const result = await inTransaction(ctx, async (tx) => {
|
||||||
|
// row lock: serialises seat changes of this tenant until commit
|
||||||
|
await tx.db.tenantSettings.updateMany({ data: { updatedAt: new Date() } });
|
||||||
|
const existing = await tx.db.lotseChatSeat.findFirst({ where: { userId: input.userId }, select: { id: true, userId: true, createdAt: true, assignedById: true } });
|
||||||
|
if (!input.assigned) {
|
||||||
|
if (!existing) return { changed: false, before: null, after: null };
|
||||||
|
await tx.db.lotseChatSeat.delete({ where: { id: existing.id } });
|
||||||
|
return { changed: true, before: { userId: existing.userId, assignedById: existing.assignedById }, after: null };
|
||||||
|
}
|
||||||
|
if (existing) return { changed: false, before: null, after: null };
|
||||||
|
const user = await tx.db.user.findFirst({ where: { AND: [{ id: input.userId }, eligibleUserWhere()] }, select: { id: true } });
|
||||||
|
if (!user) {
|
||||||
|
// unknown id, other tenant, inactive or without field role — never reveal which
|
||||||
|
throw new ServiceError("invalid", "not_eligible", { reason: "not_eligible" });
|
||||||
|
}
|
||||||
|
const assigned = await tx.db.lotseChatSeat.count();
|
||||||
|
if (assigned >= plan.lotseChatSeats) throw new ServiceError("invalid", "no_seats_left", { reason: "no_seats_left", purchased: plan.lotseChatSeats, assigned });
|
||||||
|
const row = await tx.db.lotseChatSeat.create({ data: { tenantId: tx.tenantId, userId: user.id, assignedById: ctx.userId }, select: { id: true, userId: true, assignedById: true } });
|
||||||
|
return { changed: true, before: null, after: { userId: row.userId, assignedById: row.assignedById } };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.changed) {
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: input.assigned ? "create" : "delete",
|
||||||
|
entity: "lotse_chat_seat",
|
||||||
|
entityId: input.userId,
|
||||||
|
before: result.before ?? undefined,
|
||||||
|
after: result.after ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { userId: input.userId, assigned: input.assigned, changed: result.changed };
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null };
|
|||||||
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
|
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
|
||||||
|
|
||||||
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
|
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
|
||||||
await assertLotseChatUsable(ctx);
|
await assertLotseChatUsable(ctx, { send: true }); // L17: dictation only while sending is possible
|
||||||
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
|
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
|
||||||
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
|
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
|
||||||
const sniffed = sniffMime(bytes);
|
const sniffed = sniffMime(bytes);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
|
||||||
|
import {
|
||||||
|
chatOverage,
|
||||||
|
chatQuota,
|
||||||
|
isQuotaBlocked,
|
||||||
|
monthKeyOfDay,
|
||||||
|
nextMonthKey,
|
||||||
|
overagePacks,
|
||||||
|
previousMonthKey,
|
||||||
|
usageLevel,
|
||||||
|
usagePercent,
|
||||||
|
type TenantTier,
|
||||||
|
type UsageLevel,
|
||||||
|
} from "@/lib/plans";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { getTenantPlan } from "@/server/plan";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
|
import { countAssignedSeats, countEligibleUsers } from "./seats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L17 Pakete — usage of the Lotse chat: ONE chat = one message of a user to the Lotse
|
||||||
|
* (`LotseMessage.role = user`). Counted per calendar month in the tenant timezone, for the whole tenant
|
||||||
|
* and per user. Quota = counted seats × 150 (trial: eligible users × 150). Overage keeps counting and is
|
||||||
|
* billed by the operator in packs of 100; with the hard limit the chat is blocked at the quota.
|
||||||
|
*
|
||||||
|
* Note: the Lotse retention job deletes chat histories after `AI_GENERATION_RETENTION_DAYS` (default
|
||||||
|
* 180) — keep it above ~62 days so the previous month stays complete for billing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONTH_KEY_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||||
|
|
||||||
|
export function isMonthKey(v: unknown): v is string {
|
||||||
|
return typeof v === "string" && MONTH_KEY_RE.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [from, to) instants of a calendar month in the timezone (DST-safe via day bounds). */
|
||||||
|
export function monthBounds(monthKey: string, timeZone: string): { from: Date; to: Date } {
|
||||||
|
if (!isMonthKey(monthKey)) throw new ServiceError("invalid", "month_invalid");
|
||||||
|
return { from: dayBounds(`${monthKey}-01`, timeZone).start, to: dayBounds(`${nextMonthKey(monthKey)}-01`, timeZone).start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthKeyOf(now: Date, timeZone: string): string {
|
||||||
|
return monthKeyOfDay(dayKeyOf(now, timeZone));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatUsage = {
|
||||||
|
monthKey: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
tier: TenantTier;
|
||||||
|
isTrial: boolean;
|
||||||
|
seatsPurchased: number;
|
||||||
|
seatsAssigned: number;
|
||||||
|
/** seats that count for the quota (trial: eligible users) */
|
||||||
|
quotaBasis: number;
|
||||||
|
used: number;
|
||||||
|
quota: number;
|
||||||
|
overage: number;
|
||||||
|
overagePacks: number;
|
||||||
|
level: UsageLevel;
|
||||||
|
percent: number;
|
||||||
|
hardLimit: boolean;
|
||||||
|
/** hard limit reached → sending is blocked */
|
||||||
|
blocked: boolean;
|
||||||
|
perUser: { userId: string; name: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageCtx = Pick<ServiceCtx, "db" | "tenantId">;
|
||||||
|
|
||||||
|
async function countChats(ctx: UsageCtx, from: Date, to: Date): Promise<number> {
|
||||||
|
return ctx.db.lotseMessage.count({ where: { role: "user", createdAt: { gte: from, lt: to } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countChatsPerUser(ctx: UsageCtx, from: Date, to: Date): Promise<ChatUsage["perUser"]> {
|
||||||
|
const groups = await ctx.db.lotseMessage.groupBy({ by: ["conversationId"], where: { role: "user", createdAt: { gte: from, lt: to } }, _count: { _all: true } });
|
||||||
|
if (!groups.length) return [];
|
||||||
|
const conversations = await ctx.db.lotseConversation.findMany({ where: { id: { in: groups.map((g) => g.conversationId) } }, select: { id: true, userId: true } });
|
||||||
|
const userOf = new Map(conversations.map((c) => [c.id, c.userId]));
|
||||||
|
const perUser = new Map<string, number>();
|
||||||
|
for (const g of groups) {
|
||||||
|
const userId = userOf.get(g.conversationId);
|
||||||
|
if (userId) perUser.set(userId, (perUser.get(userId) ?? 0) + g._count._all);
|
||||||
|
}
|
||||||
|
const users = await ctx.db.user.findMany({ where: { id: { in: [...perUser.keys()] } }, select: { id: true, name: true } });
|
||||||
|
const nameOf = new Map(users.map((u) => [u.id, u.name]));
|
||||||
|
return [...perUser.entries()].map(([userId, count]) => ({ userId, name: nameOf.get(userId) ?? "—", count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage of one month (default: current month in the tenant timezone). No permission check — callers are
|
||||||
|
* the chat gate (own tenant), the tenant admin view (`getChatUsageForAdmin`) and the operator view.
|
||||||
|
*/
|
||||||
|
export async function getChatUsage(ctx: UsageCtx, opts: { monthKey?: string; now?: Date; perUser?: boolean } = {}): Promise<ChatUsage> {
|
||||||
|
const timeZone = await tenantTimezone(ctx as ServiceCtx);
|
||||||
|
const monthKey = opts.monthKey ?? monthKeyOf(opts.now ?? new Date(), timeZone);
|
||||||
|
const { from, to } = monthBounds(monthKey, timeZone);
|
||||||
|
const plan = await getTenantPlan(ctx.tenantId);
|
||||||
|
const [used, seats, eligible, perUser] = await Promise.all([
|
||||||
|
countChats(ctx, from, to),
|
||||||
|
countAssignedSeats(ctx, plan.lotseChatSeats),
|
||||||
|
plan.isTrial ? countEligibleUsers(ctx) : Promise.resolve(0),
|
||||||
|
opts.perUser === false ? Promise.resolve([]) : countChatsPerUser(ctx, from, to),
|
||||||
|
]);
|
||||||
|
const quotaBasis = plan.isTrial ? eligible : seats.counted;
|
||||||
|
const quota = chatQuota(quotaBasis);
|
||||||
|
const overage = chatOverage(used, quota);
|
||||||
|
// the hard limit does not apply to a trial (no purchased seats; the token budget stays the safety net)
|
||||||
|
const hardLimit = plan.lotseChatHardLimit && !plan.isTrial;
|
||||||
|
return {
|
||||||
|
monthKey,
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
tier: plan.tier,
|
||||||
|
isTrial: plan.isTrial,
|
||||||
|
seatsPurchased: plan.lotseChatSeats,
|
||||||
|
seatsAssigned: seats.assigned,
|
||||||
|
quotaBasis,
|
||||||
|
used,
|
||||||
|
quota,
|
||||||
|
overage,
|
||||||
|
overagePacks: overagePacks(overage),
|
||||||
|
level: usageLevel(used, quota),
|
||||||
|
percent: usagePercent(used, quota),
|
||||||
|
hardLimit,
|
||||||
|
blocked: isQuotaBlocked(used, quota, hardLimit),
|
||||||
|
perUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tenant admin (`tenant:manage`): current month with per-user breakdown. */
|
||||||
|
export async function getChatUsageForAdmin(ctx: ServiceCtx, opts: { now?: Date } = {}): Promise<ChatUsage> {
|
||||||
|
assertCan(ctx, "tenant:manage");
|
||||||
|
return getChatUsage(ctx, { now: opts.now, perUser: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator view (platform admin, see services/plans/platform.ts): current and previous month. */
|
||||||
|
export async function getChatUsageForOperator(tenantId: string, now: Date = new Date()): Promise<{ current: ChatUsage; previous: ChatUsage }> {
|
||||||
|
const ctx: UsageCtx = { db: dbForTenant(tenantId), tenantId };
|
||||||
|
const current = await getChatUsage(ctx, { now, perUser: false });
|
||||||
|
const previous = await getChatUsage(ctx, { monthKey: previousMonthKey(current.monthKey), perUser: false });
|
||||||
|
return { current, previous };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
|
|||||||
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { getTenantPlan } from "@/server/plan";
|
||||||
|
import { isModuleInTier } from "@/lib/plans";
|
||||||
import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
|
import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
|
|||||||
|
|
||||||
export const LOTSE_MODULE_KEY = "lotse";
|
export const LOTSE_MODULE_KEY = "lotse";
|
||||||
|
|
||||||
|
/** Lotse usable for the tenant: module switched on AND included in the package tier (L17: Basis has no AI). */
|
||||||
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
||||||
|
if (!(await isModuleInPlan(ctx.tenantId, LOTSE_MODULE_KEY))) return false;
|
||||||
|
return isLotseSwitchedOn(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the tenant switch (settings form shows it independently of the tier). */
|
||||||
|
async function isLotseSwitchedOn(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
|
||||||
const row = await ctx.db.tenantModule.findUnique({
|
const row = await ctx.db.tenantModule.findUnique({
|
||||||
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
|
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
|
||||||
select: { enabled: true },
|
select: { enabled: true },
|
||||||
@@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">):
|
|||||||
return !row || row.enabled;
|
return !row || row.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isModuleInPlan(tenantId: string, moduleKey: string): Promise<boolean> {
|
||||||
|
return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey);
|
||||||
|
}
|
||||||
|
|
||||||
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
|
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
|
||||||
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
|
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
|
||||||
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
|
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
|
||||||
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
|
|||||||
export async function getLotseSettings(ctx: ServiceCtx) {
|
export async function getLotseSettings(ctx: ServiceCtx) {
|
||||||
assertCan(ctx, "tenant:manage");
|
assertCan(ctx, "tenant:manage");
|
||||||
const [enabled, s, budget] = await Promise.all([
|
const [enabled, s, budget] = await Promise.all([
|
||||||
isLotseEnabled(ctx),
|
isLotseSwitchedOn(ctx),
|
||||||
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
|
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
|
||||||
getTokenBudget(ctx),
|
getTokenBudget(ctx),
|
||||||
]);
|
]);
|
||||||
@@ -73,7 +86,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
|
|||||||
|
|
||||||
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
|
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
|
||||||
const before = {
|
const before = {
|
||||||
enabled: await isLotseEnabled(ctx),
|
enabled: await isLotseSwitchedOn(ctx),
|
||||||
addressForm: stored?.lotseAddressForm ?? null,
|
addressForm: stored?.lotseAddressForm ?? null,
|
||||||
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
|
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
|
||||||
chatEnabled: stored?.lotseChatEnabled ?? true,
|
chatEnabled: stored?.lotseChatEnabled ?? true,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { assertPlanFeature } from "@/server/plan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who may use the planning views (L13):
|
* Who may use the planning views (L13):
|
||||||
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
|
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
|
||||||
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
|
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
|
||||||
* - everyone else (Monteur): forbidden.
|
* - everyone else (Monteur): forbidden.
|
||||||
|
* L17 Pakete: planning is a PROFI feature — Basis → `forbidden` (reason `not_in_plan`) for board, live
|
||||||
|
* situation, recommendations, freed capacity, dashboard tiles and the planning API.
|
||||||
*/
|
*/
|
||||||
export type PlanningAccess = {
|
export type PlanningAccess = {
|
||||||
all: boolean;
|
all: boolean;
|
||||||
@@ -15,6 +18,7 @@ export type PlanningAccess = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
|
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
|
||||||
|
await assertPlanFeature(ctx.tenantId, "planning");
|
||||||
if (can(ctx, "work_order:read_all")) {
|
if (can(ctx, "work_order:read_all")) {
|
||||||
return {
|
return {
|
||||||
all: true,
|
all: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/pl
|
|||||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { assertPlanFeature } from "@/server/plan";
|
||||||
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
|
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
|
||||||
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
||||||
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
|
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
|
||||||
@@ -44,6 +45,7 @@ export type ScheduleResult = {
|
|||||||
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
|
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
|
||||||
assertCan(ctx, "work_order:assign");
|
assertCan(ctx, "work_order:assign");
|
||||||
assertCan(ctx, "work_order:write");
|
assertCan(ctx, "work_order:write");
|
||||||
|
await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete
|
||||||
const input = parseInput(scheduleSchema, raw);
|
const input = parseInput(scheduleSchema, raw);
|
||||||
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
|
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
|
||||||
|
|
||||||
|
|||||||