Modul 4 — Offerten (Cotizaciones)
Stack-Kontext: Next.js App Router · TypeScript · Tailwind 4 + shadcn/ui · Supabase (Postgres + Auth Magic-Link + RLS + Storage) · Vercel · Resend (E-Mail-Versand bereits eingerichtet). Dieses Dokument ist Teil des Pflichtenhefts „Caja" und enthält keinen Produktionscode.
1. Zweck & Scope
Das Offerten-Modul (ES: Cotizaciones) ist das kommerzielle Bindeglied zwischen dem Kontaktstamm (Modul 1 CRM) und der operativen Auftragsabwicklung (Modul 5 Logistik/ERP) sowie den Finanzen (Modul 6). Es erlaubt dem Team, aus dem Box-/Fass-Produktkatalog (Modul 3 Produkte & Preise) strukturierte Angebote mit effektiv-datierten Preislisten, Zeilen- und Gesamtrabatten sowie Affiliate-/Rabattcodes zu erstellen und diese als PDF per WhatsApp oder E-Mail (Resend) zu versenden. Der Lebenszyklus einer Offerte läuft von DRAFT bis CONVERTED, wobei ACCEPTED automatisch einen Auftrag (Modul 5), eine Rechnung (Modul 6) und — sofern ein Affiliate-Code angehängt ist — eine Provisions-Auslösung (Modul 7) bewirkt. Das Modul ist Mobile/Tablet-First ausgelegt; Quick-Entry und PDF-Versand per WhatsApp sind am Tablet und Handy vollständig nutzbar.
2. Domänenmodell
2.1 Kernentitäten und Beziehungen
contacts (1) ──────────────── (n) quotes
│
quote_lines (n) ── box_products (1)
│ └── price_list_entries (effektiv-datiert)
│
referral_codes (0..1) ── affiliates (1)
│
[accepted] ──────────── │
│ │
orders (1) ──── quote_id (FK, 1:1)
│
invoices (1) ──── order_id (FK, 1:1)
│
commission_entries (0..1) ── referral_code_id / order_id
2.2 Erläuterung
| Entität | Rolle |
|---|---|
quotes | Kopf einer Offerte (Kontakt, Gültigkeitsdatum, Rabatt Total, Status, Affiliate-Code, Versandmeta) |
quote_lines | Einzelpositionen (Produkt, Menge, Einheitspreis aus Preisliste, optionaler Zeilenrabatt) |
box_products | Produktkatalog (Modul 3, geteilt) — Box-/Fass-/Behältertypen |
price_list_entries | Effektiv-datierte Preislisten je Produkt (Modul 3) — Offertenmodul liest nur |
referral_codes | Affiliate-/Rabattcodes (Modul 7) — Offertenmodul liest und verknüpft |
affiliates | Affiliate-Stamm (Modul 7) — über referral_codes verbunden |
orders | Auftragsmodul (Modul 5) — wird bei ACCEPTED erzeugt |
invoices | Rechnungen (Modul 6) — wird bei ACCEPTED erzeugt |
commission_entries | Provisions-Eintrag (Modul 7) — entsteht pending bei Konversion (ACCEPTED → CONVERTED) + Code; Freigabe (approved) bei DELIVERED (#19) |
contacts | Kunden-/Lead-Stamm (Modul 1) — Offerte referenziert immer einen Kontakt |
3. Supabase-Schema
3.1 Enum: quote_status
CREATE TYPE quote_status AS ENUM (
'DRAFT', -- Entwurf, nur intern sichtbar
'SENT', -- Versendet (WhatsApp/E-Mail), Kunde nicht geantwortet
'ACCEPTED', -- Akzeptiert → löst Auftrag aus
'REJECTED', -- Abgelehnt
'EXPIRED', -- Gültigkeitsdatum überschritten, nicht akzeptiert
'CONVERTED' -- Auftrag erfolgreich erstellt (Ziel-Endzustand nach ACCEPTED)
);
i18n-Lookup: Inline-label_de/es/en an der Lookup-Tabelle quote_statuses (Dok 30 K-16); freie Texte in locale_strings. i18n_labels wird nicht gebaut.
| Code | DE | ES | EN |
|---|---|---|---|
DRAFT | Entwurf | Borrador | Draft |
SENT | Versendet | Enviada | Sent |
ACCEPTED | Akzeptiert | Aceptada | Accepted |
REJECTED | Abgelehnt | Rechazada | Rejected |
EXPIRED | Abgelaufen | Vencida | Expired |
CONVERTED | Umgewandelt | Convertida | Converted |
3.2 Enum: discount_type
CREATE TYPE discount_type AS ENUM (
'PERCENT', -- prozentualer Rabatt (0–100)
'ABSOLUTE' -- absoluter Betrag in CHF
);
3.3 Tabelle: quotes
CREATE TABLE quotes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Kopffelder
quote_number text NOT NULL UNIQUE, -- z.B. "QUO-2026-0042" (erzeugt via Sequenz)
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT,
recipient_name text, -- F-24: Freitext-Empfänger fürs PDF (früh, vor KYC; kein FK auf recipients)
recipient_address text, -- optionale Lieferadresse (Abholort CH)
-- F-24: Der strukturierte DR-Empfänger entsteht erst bei der Sendung (shipments.recipient_id → recipients, Modul 14 §4.3).
-- Dieser Freitext kann dort als Vorschlag vorbelegt werden, wird aber nicht automatisch zur recipients-Zeile.
-- Optionale quotes.recipient_id → recipients bleibt spätere Ausbaustufe (Dok 30 §2.5).
-- Gültigkeit
issue_date date NOT NULL DEFAULT CURRENT_DATE,
valid_until date NOT NULL, -- Standard: issue_date + 14 Tage (konfigurierbar)
-- Rabatt auf Gesamtbetrag (zusätzlich zu Zeilenrabatten)
total_discount_type discount_type, -- NULL = kein Gesamtrabatt
total_discount_value numeric(10,2) CHECK (total_discount_value >= 0),
-- Affiliate-/Rabattcode
referral_code_id uuid REFERENCES referral_codes(id) ON DELETE SET NULL,
referral_code_text text, -- Snapshot des Codes zum Zeitpunkt der Erstellung
-- Code-Rabatt = Kundenrabatt (O-08, Entscheid 2026-06-28): sichtbarer Preisnachlass, mindert grand_total, auf PDF
discount_from_code_chf numeric(12,2), -- berechneter Code-Rabatt in CHF (NULL = kein Code/kein Rabatt)
discount_from_code_type discount_type, -- Snapshot der Code-Rabatt-Regel (PERCENT|ABSOLUTE) bei Anwendung
discount_from_code_value numeric(10,4), -- Snapshot des Code-Rabatt-Werts bei Anwendung
-- Beträge (kalkuliert + gespeichert für PDF/Reporting)
subtotal_chf numeric(12,2) NOT NULL DEFAULT 0, -- Summe Zeilenbeträge nach Zeilenrabatten
discount_total_chf numeric(12,2) NOT NULL DEFAULT 0, -- berechneter Gesamtrabatt CHF (manueller Gesamtrabatt)
grand_total_chf numeric(12,2) NOT NULL DEFAULT 0, -- Endbetrag inkl. aller Rabatte (Zeilen- + Gesamt- + Code-Rabatt)
-- Mehrwertsteuer (schema-ready, initial nicht aktiv)
vat_rate_percent numeric(5,2), -- NULL = kein MwSt-Ausweis; z.B. 8.1
vat_amount_chf numeric(12,2),
-- Notizen
internal_notes text, -- nicht auf PDF
customer_notes text, -- erscheint auf PDF
-- Status
status quote_status NOT NULL DEFAULT 'DRAFT',
-- Versand-Metadaten
sent_at timestamptz,
sent_via text[], -- z.B. ['whatsapp', 'email']
pdf_storage_path text, -- Supabase Storage Bucket-Pfad
-- Konversionsziel
order_id uuid REFERENCES orders(id) ON DELETE SET NULL, -- gesetzt bei CONVERTED
-- Team/Ownership
created_by uuid NOT NULL REFERENCES auth.users(id),
updated_by uuid REFERENCES auth.users(id),
-- Zeitstempel
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Indizes
CREATE INDEX idx_quotes_contact_id ON quotes(contact_id);
CREATE INDEX idx_quotes_status ON quotes(status);
CREATE INDEX idx_quotes_issue_date ON quotes(issue_date DESC);
CREATE INDEX idx_quotes_referral ON quotes(referral_code_id) WHERE referral_code_id IS NOT NULL;
-- Sequenz für Offertennummer
CREATE SEQUENCE quote_number_seq START 1;
-- Nutzung via: 'QUO-' || EXTRACT(YEAR FROM now()) || '-' || LPAD(nextval('quote_number_seq')::text, 4, '0')
-- Entscheid 2026-06-28: JÄHRLICHER Reset → Zähler beginnt jeden 1.1. neu bei 0001
-- (Cron-Reset der Sequenz zum Jahreswechsel ODER per-Jahr-Zähler via max(lfd)+1 je Jahr).
-- Eindeutigkeit über das Jahr-Präfix: QUO-2026-0001 … QUO-2027-0001.
3.4 Tabelle: quote_lines
CREATE TABLE quote_lines (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id uuid NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
-- Position
position smallint NOT NULL CHECK (position > 0), -- Anzeigereihenfolge
-- Produkt
product_id uuid NOT NULL REFERENCES box_products(id) ON DELETE RESTRICT,
product_snapshot jsonb NOT NULL, -- Snapshot {code, name_de, name_es, name_en} zum Zeitpunkt der Erstellung
-- Preisbasis
price_list_entry_id uuid REFERENCES price_list_entries(id) ON DELETE SET NULL, -- welche Preisliste wurde verwendet
unit_price_chf numeric(10,2) NOT NULL CHECK (unit_price_chf >= 0), -- Einzelpreis aus Preisliste (Snapshot)
zone text, -- Zielzone/Zielort DR (falls preisrelevant)
-- Menge
quantity numeric(8,2) NOT NULL CHECK (quantity > 0),
unit text NOT NULL DEFAULT 'Stk', -- 'Stk', 'L' (Liter bei Fässern), etc.
-- Zeilenrabatt
line_discount_type discount_type, -- NULL = kein Zeilenrabatt
line_discount_value numeric(10,2) CHECK (line_discount_value >= 0),
-- Berechnete Felder (gespeichert für PDF/Audit)
line_subtotal_chf numeric(12,2) NOT NULL DEFAULT 0, -- quantity × unit_price ohne Rabatt
line_discount_chf numeric(12,2) NOT NULL DEFAULT 0, -- berechneter Rabatt CHF
line_total_chf numeric(12,2) NOT NULL DEFAULT 0, -- Zeilenbetrag nach Rabatt
-- Notiz auf Zeile (z.B. "inkl. Depot CHF 30")
line_note text,
UNIQUE (quote_id, position)
);
CREATE INDEX idx_quote_lines_quote_id ON quote_lines(quote_id);
3.5 Abhängige Tabellen (Referenzen, nicht vollständig hier definiert)
Diese Tabellen werden in anderen Modul-Specs vollständig definiert; hier nur die für das Offerten-Modul relevanten Spalten:
box_products (Modul 3): id, code (z.B. CAJA_JUMBO), category (box|barrel|bin|service), name_de, name_es, name_en, active.
price_list_entries (Modul 3): id, product_id, price_list_id, zone, unit_price_chf, effective_from, effective_to (NULL = aktuell gültig).
referral_codes (Modul 7): id, code (Text, unique), affiliate_id, discount_type (PERCENT|ABSOLUTE), discount_value, active, valid_from, valid_until.
orders (Modul 5): id, order_number, quote_id (FK 1:1), contact_id, status, created_at.
invoices (Modul 6): id, invoice_number, order_id, contact_id, total_chf, status.
commission_entries (Modul 7): id, referral_code_id, affiliate_id, order_id, base_amount_chf, commission_amount_chf, status (pending→approved bei DELIVERED→paid).
3.6 RLS-Regeln (Row Level Security)
Rollen: ADMIN (Marcel, Mariela), OPERATIONS (Markus), FAHRER (Arkys), AFFILIATE (externer Affiliate), READONLY. RLS-Prüfung ausschliesslich über has_role('CODE') / current_affiliate_id() (Quelle: user_roles), nie über einen JWT-Claim.
-- quotes: Lesen
-- ADMIN/OPERATIONS: alle Offerten
-- FAHRER: keine (Offerten sind nicht Feldteam-relevant)
-- AFFILIATE: nur eigene (via referral_code_id → affiliates.user_id = auth.uid())
-- READONLY: keine
ALTER TABLE quotes ENABLE ROW LEVEL SECURITY;
CREATE POLICY "quotes_select_staff" ON quotes
FOR SELECT USING (
(has_role('ADMIN') or has_role('OPERATIONS'))
);
CREATE POLICY "quotes_select_affiliate" ON quotes
FOR SELECT USING (
(current_affiliate_id() is not null)
AND referral_code_id IN (
SELECT rc.id FROM referral_codes rc
JOIN affiliates a ON a.id = rc.affiliate_id
WHERE a.user_id = auth.uid()
)
);
-- quotes: Schreiben
-- ADMIN/OPERATIONS: erstellen, bearbeiten (nur DRAFT + SENT)
-- kein Löschen von ACCEPTED/CONVERTED (Revisionssicherheit)
CREATE POLICY "quotes_insert_staff" ON quotes
FOR INSERT WITH CHECK (
(has_role('ADMIN') or has_role('OPERATIONS'))
);
CREATE POLICY "quotes_update_staff" ON quotes
FOR UPDATE USING (
(has_role('ADMIN') or has_role('OPERATIONS'))
AND status IN ('DRAFT', 'SENT') -- ACCEPTED+ nur via Server-Side Action änderbar
);
-- quote_lines: gleiche Policies wie quotes (via quote_id)
ALTER TABLE quote_lines ENABLE ROW LEVEL SECURITY;
CREATE POLICY "quote_lines_staff" ON quote_lines
FOR ALL USING (
EXISTS (
SELECT 1 FROM quotes q WHERE q.id = quote_lines.quote_id
AND (has_role('ADMIN') or has_role('OPERATIONS'))
)
);
Wichtig: Status-Übergänge nach ACCEPTED, CONVERTED und die Auftragserstellung erfolgen ausschliesslich via Supabase Edge Function oder Next.js Server Action (Server-Side), nie direkt durch Client-RLS-Änderungen.
4. Kern-Workflows
4.1 Offerte erstellen (DRAFT)
- Benutzer (admin/ops) öffnet „Neue Offerte" — Kontakt wählen (Suche in
contacts) oder Inline-Schnellerfassung wenn noch nicht im Stamm. - Gültigkeitsdatum setzen (Default: heute + 14 Tage, konfigurierbar via Settings).
- Positionen hinzufügen: Produkt aus Katalog wählen → aktuell gültige Preisliste wird automatisch gezogen (
price_list_entriesWHEREeffective_from ≤ todayAND (effective_to IS NULLOReffective_to ≥ today) ORDER BYeffective_from DESC LIMIT 1). Zone/Zielort auswählbar wenn preisrelevant. - Menge eingeben (inputmode numeric, Bottom-Sheet-Picker am Handy); Zeilenrabatt optional (Prozent oder absolut).
- Optional: Gesamtrabatt (Prozent oder absolut) auf Summenlevel.
- Optional: Affiliate-/Rabattcode eingeben → System prüft
referral_codesauf Gültigkeit (active=true, Datum), zeigt den Rabattbetrag live an und verbucht ihn als Kundenrabatt indiscount_from_code_chf(separater Posten, mindertgrand_total_chf, auf PDF ausgewiesen — O-08, Entscheid 2026-06-28). Snapshot der Code-Regel (discount_from_code_type/value) wird gespeichert. - Interne Notizen und Kundennotizen (erscheinen auf PDF) ergänzen.
- Beträge werden client-seitig kalkuliert und beim Speichern server-seitig validiert und in
quotes.subtotal_chf / discount_total_chf / grand_total_chfpersistiert. - Status bleibt
DRAFT; Offerte wird gespeichert.
Edge Cases:
- Produkt ohne gültige Preisliste: Warnung, manuelle Preiseingabe erzwingen (Unit-Price editierbar), Snapshot enthält
price_list_entry_id = NULL. - Zeilenrabatt + Gesamtrabatt: Reihenfolge klar definiert — erst Zeilenrabatt anwenden, dann Gesamtrabatt auf die Summe. Kein Doppelrabatt-Siloing.
- Affiliate-Code abgelaufen oder inaktiv: Fehlermeldung inline, Code wird nicht gesetzt.
- Kontakt ohne E-Mail und Telefon: Warnung beim Versandversuch, aber Speichern bleibt erlaubt.
4.2 Offerte versenden (DRAFT → SENT)
- „Versenden" öffnet Versand-Dialog: Kanal wählen (WhatsApp / E-Mail / Beide).
- PDF wird serverseitig generiert (Next.js Server Action, Bibliothek: 🔲 zu bestätigen — Kandidaten:
@react-pdf/rendereroder Puppeteer-basiert auf Vercel). PDF-Inhalt: Kopfdaten, Positionen, Rabatte (Zeilen-, Gesamt- und Code-Rabatt als eigener Posten/Kundenrabatt, O-08), Gesamtbetrag, Gültigkeitsdatum, Affiliate-Code wenn vorhanden, Kundennotizen, Firmen-Branding. - PDF wird in Supabase Storage abgelegt (
quotes/{quote_id}/QUO-YYYY-NNNN.pdf);quotes.pdf_storage_pathwird gesetzt. - WhatsApp: Deep-Link
https://wa.me/{phone}?text={encodedMessage}öffnet WhatsApp mit vorausgefülltem Text und PDF-Downloadlink (signierter Storage-URL). Am Handy öffnet sich WhatsApp direkt. - E-Mail: Resend (bereits eingerichtet,
RESEND_FROM = @dominicanoexpress.com) sendet E-Mail mit PDF-Anhang ancontacts.email. Template mehrsprachig (DE/ES/EN nachcontacts.preferred_lang). - Status →
SENT;sent_at = now();sent_viaArray wird befüllt.
Edge Cases:
- PDF-Generierung schlägt fehl: Toast-Fehler, Status bleibt DRAFT, kein Versand.
- Kontakt hat keine E-Mail: E-Mail-Kanal ist deaktiviert (nicht auswählbar).
- Kontakt hat keine Telefonnummer: WhatsApp-Kanal ist deaktiviert.
- Erneuter Versand (SENT → SENT): erlaubt, neues PDF ersetzt altes (
overwrite),sent_viawird ergänzt.
4.3 Status-Übergänge (manuell + automatisch)
| Von | Nach | Auslöser | Aktion |
|---|---|---|---|
DRAFT | SENT | Versand-Aktion (§4.2) | PDF erzeugen, versenden |
DRAFT | REJECTED | manuell (Kontakt sagt ab) | kein Follow-up |
SENT | ACCEPTED | manuell (Kontakt sagt zu) | → §4.4 Konversion |
SENT | REJECTED | manuell | kein Follow-up |
SENT | EXPIRED | Cron-Job täglich (valid_until < today, status = SENT) | automatisch |
ACCEPTED | CONVERTED | Server Action nach Auftragsanlage | order_id setzen |
| Jeder | DRAFT | Duplikate-/Kopier-Funktion → neue Offerte | neue ID, Status DRAFT |
4.4 Offerte akzeptieren → Konversion (ACCEPTED → CONVERTED)
- Benutzer klickt „Akzeptieren" (nur admin/ops).
- Server Action (transaktional):
a.
quotes.status→ACCEPTED. b. Neuen Auftrag anlegen:orders.INSERTmitquote_id(FK),customer_id,status = OPEN. Keine Kopie der Positionen in eineorder_lines-Tabelle (F-01) — die Positionen bleiben im (eingefrorenen)quote_linesder Offerte; die Boxen werden in Modul 5 direkt ausquote_linesmaterialisiert (je Position Nboxesmit Produkt-Snapshot überbox_product_id). c. Rechnung anlegen:invoices.INSERTmitorder_id,contact_id,total_chf = quotes.grand_total_chf,status = OPEN. Diese Rechnung bucht über das Auto-Posting (invoice_issued→INVOICE) die Forderung ins Ledger — die Offerten-Annahme selbst bucht keineRECEIVABLE(F-06, keine Doppelzählung). d. Wennreferral_code_id IS NOT NULL:referral_code_id+affiliate_idin den Auftrag übernehmen und dencommission_entries-Eintrag (Modul 7) mitstatus='pending'anlegen (#19 — Entstehung bei Konversion, Modul 16 §4.5 A). Die Freigabe/Fälligkeit (status → 'approved') erfolgt später automatisch beim Tracking-DELIVEREDder Sendung (Modul 5 / Modul 16 §4.5 B) — nicht beiinvoices.status → 'PAID'. e.quotes.status→CONVERTED;quotes.order_idsetzen. - Toast-Bestätigung mit Links zu Auftrag und Rechnung.
- Audit-Log-Eintrag für jeden der obigen Schritte (
audit_log.action = 'quote_converted',source_type = 'quote',source_id = quote_id).
Edge Cases:
- Transaktion schlägt teilweise fehl: vollständiger Rollback; Status bleibt
ACCEPTED(noch nichtCONVERTED); Fehlermeldung zeigt, welcher Schritt fehlschlug. - Auftrag wurde bereits manuell angelegt (Duplicate-Guard):
ordershat Unique-Constraint aufquote_id— Versuch wird mit Konfliktfehler abgefangen. - Offerte abgelaufen (
EXPIRED) aber nachträglich akzeptiert: Warnung, aber Konversion ist erlaubt nach expliziter Bestätigung durch admin.
4.5 Offerte ablehnen / ablaufen lassen
- Manuell abgelehnt: Status →
REJECTED, Grund optional ininternal_notes. - Abgelaufen: Täglicher Cron-Job (Vercel Cron oder Supabase pg_cron):
UPDATE quotes SET status = 'EXPIRED' WHERE valid_until < CURRENT_DATE AND status = 'SENT'.
4.6 Offerte kopieren / neu auflegen
- Aus jeder Offerte (beliebiger Status) kann eine Kopie erstellt werden.
- Neue Offerte erhält neue ID, neue
quote_number, StatusDRAFT,issue_date = today,valid_until = today + 14. - Positionen, Rabatte und Notizen werden übernommen;
sent_at,pdf_storage_path,order_idwerden nicht übernommen. - Preise werden neu aus der aktuellen Preisliste gezogen (nicht der Snapshot), mit Hinweis im UI.
5. UI-Screens (Mobile/Tablet-First)
5.1 Offerten-Liste
Zweck: Übersicht aller Offerten mit Status-Ampel, Schnellfilter, CTA.
| Formfaktor | Pattern |
|---|---|
| Handy (375–767px) | Karten-Liste (keine Tabelle). Jede Karte: Quote-Nummer + Kontaktname (gross), Grand-Total rechts, Status-Badge (farbig), Datum klein. Swipe-Right = Versenden, Swipe-Left = Ablehnen (nur DRAFT/SENT). FAB „+" unten rechts. Bottom-Tab-Bar mit Modul-Navigation. |
| Tablet (768–1279px) | Kompakte Tabellen-Ansicht (5–6 Spalten sichtbar ohne Scroll): Nr., Kontakt, Datum, Gültig bis, Total CHF, Status. Row-Tap öffnet Detail-Panel rechts (Split-View). Sidebar-Navigation links. |
| Desktop (≥1280px) | Vollständige TanStack-Tabelle mit Sortierung, Filterung, Multi-Select für Batch-Aktionen. Detail-Panel oder Navigieren zur Detail-Seite. |
Filter (persistent in URL): Status (Multi-Select), Datumsbereich, Kontakt-Suche, Affiliate-Code vorhanden (Ja/Nein).
Skeleton: 5 Karten/Zeilen während Ladephase.
Empty State: Illustration + „Erste Offerte erstellen" → direkt zur Erstellungsmaske.
5.2 Offerten-Erstellung / -Bearbeitung
Zweck: Neues Angebot erstellen oder DRAFT/SENT bearbeiten.
| Formfaktor | Pattern |
|---|---|
| Handy | Wizard/Stepper: Schritt 1 Kontakt, Schritt 2 Positionen, Schritt 3 Rabatte & Code, Schritt 4 Notizen & Versand. Jeder Schritt = eigener Screen mit „Weiter"-Button (≥44px, Thumb-Reach unten). Produkt-Auswahl via Bottom-Sheet mit Suchfeld. Mengen-Eingabe via inputmode="numeric" mit ± Buttons (44px). Rabatt-Typ-Auswahl via Bottom-Sheet (Prozent / Absolut). |
| Tablet | Einseitiges Formular: Kopfdaten oben (Kontakt, Datum), Positionen-Tabelle (inline editierbar mit TanStack), Rabatt-Panel rechts, Versand-Panel unten. Kein Wizard nötig. |
| Desktop | Identisch Tablet; breiteres Layout mit persistentem Vorschau-Panel (PDF-Preview rechts). |
Produktauswahl: Kamera-Icon am Handy für QR-Scan eines Box-Etiketts → direkt Produkt vorausfüllen (Nice-to-have, 🔲 zu bestätigen).
Preisanzeige: Beim Produktwählen wird Einheitspreis sofort angezeigt; Zeilenrabatt und Total aktualisieren live.
Affiliate-Code-Feld: Texteingabe mit Inline-Validierung (grüner Haken bei gültigem Code, roter Fehler bei ungültigem). Rabatt-Anzeige erscheint unmittelbar.
5.3 Offerten-Detail
Zweck: Lesende Ansicht einer Offerte; Status-Übergänge; Versand; Konversion.
| Bereich | Inhalt |
|---|---|
| Header | Quote-Nummer, Kontaktname (Link zum CRM), Status-Badge, Aktionsleiste |
| Aktionsleiste | „Versenden", „Akzeptieren", „Ablehnen", „Kopieren", „PDF herunterladen" — je nach aktuellem Status sichtbar/deaktiviert |
| Positionen | Tabelle/Karten (adaptiv wie Liste) |
| Rabatt-Zusammenfassung | Zwischensumme, Zeilenrabatte, Gesamtrabatt, Affiliate-Rabatt, Endbetrag |
| Versand-History | Wann / Kanal versendet |
| Integrationen | Link zu Auftrag (wenn CONVERTED), Link zur Rechnung |
Handy-Spezifik: Header + Aktions-Buttons fixiert oben; Inhalt scrollbar. „Akzeptieren" = prominenter Primary-Button (grün, volle Breite) am Ende des Screens.
5.4 Versand-Dialog
Zweck: Kanal wählen, Nachricht anpassen, Versand auslösen.
| Formfaktor | Pattern |
|---|---|
| Handy | Bottom-Sheet (volle Höhe) |
| Tablet/Desktop | Modal-Dialog (shadcn/ui Dialog) |
Inhalt: Kanal-Toggle (WhatsApp / E-Mail / Beide), Vorschau der WhatsApp-Nachricht (editierbar), E-Mail-Betreff/-Text (editierbar, mehrsprachig), Anhang-Vorschau (PDF-Name). „Jetzt senden"-Button.
5.5 PDF-Vorschau (Read-only)
- Öffnet Supabase-Storage-URL in neuem Tab oder
<iframe>im Panel. - Signierte URL (zeitlich begrenzt, 1h).
- Download-Button für lokale Kopie.
6. Integrationen & Verbindungen zu anderen Modulen
6.1 Modul 1 — CRM/Kontaktstamm
quotes.contact_id→contacts.id: Offerte ist immer einem Kontakt zugeordnet.- Kontaktsuche in der Offerte: Volltextsuche auf
contacts.first_name,contacts.last_name,contacts.phone. - Lead-Status-Update: Wenn eine Offerte versendet wird (
SENT), kann der Lead-Status des Kontakts automatisch aufQUOTE_SENTgesetzt werden (🔲 zu bestätigen, ob gewünscht).
6.2 Modul 3 — Produkte & Preise
quote_lines.product_id→box_products.id.quote_lines.price_list_entry_id→price_list_entries.id(Snapshot zum Erstellungszeitpunkt).- Preislisten-Abfrage: beim Hinzufügen einer Position wird die aktuell gültige
price_list_entryfürproduct_id+ optionalzoneabgefragt. - Snapshot (
product_snapshotJSONB) sichert Produktnamen und Preis für spätere Anzeige unabhängig von Katalogänderungen.
6.3 Modul 5 — Logistik/ERP
- Bei
ACCEPTED→CONVERTED:orders.INSERTmitquote_id(FK 1:1, Unique-Constraint). - Keine
order_lines-Tabelle (F-01): Die Positionen bleiben im eingefrorenenquote_linesder konvertierten Offerte (orders.quote_id→quotes→quote_lines). Modul 5 materialisiert die Boxen direkt ausquote_lines(pro Position Anzahl × Box-Typ → Nboxesmit Produkt-Snapshot viabox_product_id). - Container-Zuweisung, Box-UUID-Generierung, 10-Schritt-Tracking: vollständig im Auftragsmodul (Modul 5); Offerten-Modul triggert nur die Anlage.
6.4 Modul 6 — Finanzen
- Bei
ACCEPTED→CONVERTED:invoices.INSERT;total_chf = quotes.grand_total_chf. - Die Rechnungsstellung erzeugt via Auto-Posting genau eine
INVOICE-Bewegung (source_type = 'invoice_issued',source_id = invoice.id) → das ist die einmalige Forderungsbuchung (F-06). Die spätere Kundenzahlung buchtINVOICE_PAYMENT(source_type = 'debtor_payment') +payment_links. - Die Offerten-Annahme selbst bucht keine
RECEIVABLE-Vormerkung (würde dieselbe Forderung doppelt zählen). Das Offerten-Modul bucht nichts direkt insmovements-Ledger — Auslöser ist die Rechnung (Finanzmodul).
6.5 Modul 7 — Affiliate-Programm
quotes.referral_code_id→referral_codes.id: Affiliate-Code wird auf Offerte gespeichert; der Code-Rabatt ist ein Kundenrabatt (mindertgrand_total_chf, auf PDF ausgewiesen — siehe O-08).- Bei
CONVERTED:commission_entries.INSERTmitstatus='pending'und Provisions-Berechnung (#19 — %-Satz je Affiliate; Logik in Modul 7 §4.5 A). Die Freigabe (approved) erfolgt beim Tracking-DELIVERED(Modul 7 §4.5 B), die Auszahlung beim Payout. referral_code_text(Snapshot des Code-Strings) bleibt auf der Offerte erhalten, auch wenn der Code später deaktiviert wird.
6.6 Shared Events / Auto-Posting-Auslöser
| Event | Auslöser | Empfänger-Modul |
|---|---|---|
quote.sent | Status → SENT | (optional) CRM: Lead-Status-Update |
quote.accepted | Status → ACCEPTED | Modul 5: Order anlegen; Modul 6: Invoice anlegen |
quote.converted | Status → CONVERTED | Modul 7: commission_entries (pending) anlegen (#19; Freigabe später bei DELIVERED) |
quote.expired | Cron-Job täglich | (keine Auto-Aktion, nur Status-Update) |
7. Validierungen & Edge-Cases
7.1 Berechnungs-Validierung (server-seitig, immer)
// Pseudo-Typdefinition zur Illustration
type QuoteCalculation = {
lines: Array<{
quantity: number; // > 0
unit_price_chf: number; // >= 0
line_discount_type: 'PERCENT' | 'ABSOLUTE' | null;
line_discount_value: number; // >= 0; wenn PERCENT: 0–100
}>;
total_discount_type: 'PERCENT' | 'ABSOLUTE' | null;
total_discount_value: number; // >= 0; wenn PERCENT: 0–100
};
// Berechnungsreihenfolge (kanonisch):
// 1. line_subtotal = quantity × unit_price_chf
// 2. line_discount_chf = PERCENT: line_subtotal × rate / 100 | ABSOLUTE: min(value, line_subtotal)
// 3. line_total = line_subtotal - line_discount_chf
// 4. subtotal = Σ line_total
// 5. discount_total_chf = PERCENT: subtotal × rate / 100 | ABSOLUTE: min(value, subtotal)
// 6. discount_from_code_chf (Kundenrabatt aus Code, O-08) =
// PERCENT: (subtotal - discount_total_chf) × rate / 100, ggf. gedeckelt auf discount_max_chf
// ABSOLUTE: min(value, subtotal - discount_total_chf)
// 7. grand_total = subtotal - discount_total_chf - discount_from_code_chf (nie < 0; Rabatte werden gecapped)
// Alle Rundungen: HALF_UP auf 2 Dezimalstellen, erst am Ende jeder Zeile
7.2 Status-Übergänge: erlaubte Transitionen
DRAFT → SENT, REJECTED
SENT → ACCEPTED, REJECTED, EXPIRED (Cron)
ACCEPTED → CONVERTED (nur nach erfolgreicher Order-Anlage)
REJECTED → (keine weiteren Transitionen; Kopie als neues DRAFT möglich)
EXPIRED → ACCEPTED (nur mit expliziter admin-Bestätigung)
CONVERTED → (keine weiteren Transitionen)
Unerlaubte Transitionen werden server-seitig mit HTTP 409 Conflict abgefangen.
7.3 Weitere Edge-Cases
| Situation | Behandlung |
|---|---|
| Produkt aus Katalog entfernt, Offerte existiert noch | product_snapshot JSONB sichert Anzeige; box_products hat ON DELETE RESTRICT → Produkt kann nur deaktiviert, nicht gelöscht werden |
| Preislistenänderung nach Offertenerstellung | Snapshot in quote_lines.unit_price_chf + product_snapshot bleibt; „Preise aktualisieren"-Button in DRAFT erlaubt explizites Neuziehen |
| Doppelter Affiliate-Code auf zwei gleichzeitigen Offerten | Erlaubt; Commission wird pro Offerte/Auftrag berechnet |
| Affiliate-Code-Rabatt + Gesamt-Rabatt | Beide werden akkumuliert (O-08, Entscheid 2026-06-28): erst Zeilenrabatte, dann manueller Gesamtrabatt, dann Code-Rabatt als separater Posten (discount_from_code_chf) — alle drei mindern grand_total_chf und erscheinen einzeln auf dem PDF (§7.1 Schritte 6–7) |
| Grand Total < 0 nach Rabatten | Validierungsfehler: Endbetrag darf nicht negativ werden; Rabatt wird auf maximum des Subtotals gecapped |
| Offerte in falscher Sprache versendet | contacts.preferred_lang steuert Sprache des PDF + E-Mail-Templates; Benutzer kann Sprache im Versand-Dialog übersteuern |
| Resend-API-Fehler beim Versenden | Toast-Fehler, Status bleibt SENT (oder wird nicht auf SENT gesetzt wenn auch PDF-Upload fehlschlägt); Retry-Button im Versand-Dialog |
| Supabase Storage Upload schlägt fehl | Ganzer Versand-Flow schlägt fehl; kein Teilzustand; Fehlermeldung mit Detail |
8. Compliance- und Sicherheits-Hinweise
8.1 Revisionssicherheit (OR 957 / GeBüV)
- Offerten mit Status
ACCEPTEDoderCONVERTEDdürfen nicht verändert oder gelöscht werden. RLS-Policy erlaubtUPDATEnur beistatus IN ('DRAFT', 'SENT')(clientseitig); serverseitig zusätzliche Guard-Prüfung in der Server Action. - Das
audit_logprotokolliert jeden Status-Übergang, jeden Versand und die Konversion mituser_id,timestamp,old_status,new_status,source_type = 'quote',source_id. Das Audit-Log ist append-only (kein UPDATE/DELETE via RLS). - PDF-Dateien in Supabase Storage sind unveränderlich (jede neue Version erhält einen neuen Pfad mit Timestamp); alte Versionen werden nicht überschrieben.
- Betrags-Snapshots (
subtotal_chf,grand_total_chf, Zeilen-Snapshots) sichern den Vertragsinhalt zum Zeitpunkt der Akzeptanz — auch wenn Preislisten sich später ändern.
8.2 Datenschutz (revDSG / DSG)
quotesundquote_linesenthalten Personendaten (übercontact_id). RLS stellt sicher, dass nur befugte Rollen zugreifen können.- PDF-Downloads aus Supabase Storage nur via signierte URLs (zeitlich begrenzt, max. 1h). Keine öffentlichen Buckets für Offerten-PDFs.
- WhatsApp-Versand: Deep-Link öffnet WhatsApp-App des Benutzers; Caja sendet keine Daten direkt an WhatsApp-API. Der Benutzer löst den Versand manuell aus. Hinweis im UI: „Die Offerte wird über Ihr WhatsApp versendet."
- Affiliate sieht via RLS nur Offerten mit dem eigenen Referral-Code — keine Einsicht in andere Kunden oder Beträge anderer Offerten.
8.3 Mehrwertsteuer (MWST, schema-ready)
quotes.vat_rate_percentundquotes.vat_amount_chfsind im Schema vorhanden, initialNULL.- Wenn aktiviert: MWST-Ausweis auf PDF,
vat_amount_chf = grand_total_chf × vat_rate / 100(oder Netto-Basis je nach Konfiguration). - Aktivierung über Settings-Tabelle (
settings.vat_enabled = true), nicht im Code verdrahtet. - Aktueller Stand: Schweizer KMU unter MWST-Pflichtgrenze — MWST initial deaktiviert (🔲 zu bestätigen: ab wann MWST-Pflicht für Dominicano Express GmbH?).
8.4 Zahlensicherheit / Arithmetik
- Alle Geldbeträge als
numeric(12,2)in Postgres (keine Floating-Point-Typen). - Server-seitige Neuberechnung bei jedem Speichern; Client-Anzeige ist nur Preview.
- Rundung: HALF_UP auf 2 Dezimalstellen per Zeile, dann Summe (nicht umgekehrt).
9. Offene Punkte (🔲 zu bestätigen)
| # | Punkt | Kontext |
|---|---|---|
| ✅ O-01 | Rabatt-Stapelung Affiliate-Code + Gesamtrabatt (entschieden 2026-06-28, mit O-08) | Der Code-Rabatt ist ein separater Posten (discount_from_code_chf) nach dem manuellen Gesamtrabatt und wird auf dem PDF einzeln ausgewiesen (nicht im Gesamtrabatt-Feld zusammengefasst). Berechnung §7.1 Schritte 6–7. |
| 🔲 O-02 | PDF-Bibliothek: @react-pdf/renderer (rein clientseitig/serverless-kompatibel) vs. Puppeteer/Playwright (besser für komplexe Layouts, aber grössere Lambda). Vercel Serverless Limit beachten. | Modul 3 (Preislisten-PDF) könnte dieselbe Lib nutzen → gemeinsame Entscheidung |
| 🔲 O-03 | Lead-Status-Update bei Versand: Soll contacts.lead_status automatisch auf QUOTE_SENT wechseln, wenn eine Offerte versendet wird? | Modul 1 (CRM) muss Lead-Status-Machine kennen |
| 🔲 O-04 | Gültigkeitsdauer Default: 14 Tage vorgeschlagen — entspricht das der geschäftlichen Praxis? Saisonal variabel? | Settings-Tabelle kann Default halten |
| 🔲 O-05 | Kamera-Scan Produkt-QR: Box-QR-Code im Offerten-Erstellungsfluss am Handy nutzen (Produkt-ID direkt einscannen) — gewünscht oder erst in Logistik-Modul? | Kamera-Infra wird in Modul 5 (Box-UUID-Scan) sowieso gebaut |
| 🔲 O-06 | Mehrsprachiges PDF: Sprache des PDF richtet sich nach contacts.preferred_lang. Soll der Benutzer im Versand-Dialog die Sprache übersteuern können? | Betrifft alle 3 Sprachen (DE/ES/EN) |
| 🔲 O-07 | MWST-Pflicht: Ab welchem Umsatz/Datum tritt MWST-Pflicht für Dominicano Express GmbH ein? Schema ist ready, Aktivierung via Settings. | Abstimmen mit Treuhänder |
| ✅ O-08 | Affiliate-Code-Rabatt Verrechnungslogik (entschieden 2026-06-28) | Kundenrabatt: Der Code gibt dem Kunden einen sichtbaren Preisnachlass. Der Rabatt wird in quotes.discount_from_code_chf berechnet, mindert grand_total_chf und wird auf dem Offerten-PDF als eigener Posten ausgewiesen — nicht nur interne Provisionsbasis. (Provisionsbasis ist der Netto-Sendungswert, Modul 7 §4.5.) |
| ✅ O-09 | Offerten-Nummernkreis (entschieden 2026-06-28): jährlich zurückgesetzt (QUO-2026-0001, QUO-2027-0001). | Eindeutigkeit über Jahr-Präfix; pro Jahr lückenlos zählen |
| 🔲 O-10 | Expired-Reaktivierung: Darf ein admin eine EXPIRED-Offerte auf ACCEPTED setzen (mit Warnung) oder muss zwingend eine neue Kopie erstellt werden? | Kompromiss zwischen Praxiskomfort und sauberer Audit-Spur |
IST-Paritäts-Nachtrag: Preis-Override-Recht (BR-25)
Das Alt-ERP kannte ein dediziertes Recht InvoiceEditPrice(15) zum manuellen Überschreiben des berechneten Preises (im IST nur frontend-seitig durchgesetzt — Bug BR-46). Caja setzt es serverseitig autoritativ durch:
- Der vom Preis-Resolver (Modul 3 §4) gelieferte
unit_price_chfist der Default. Ein Abweichen (quote_lines.unit_price_chf ≠ aufgelöster Preis) ist ein privilegierter Vorgang. - Recht
PRICE_OVERRIDE(DefaultADMIN, optionalOPERATIONS— 🔲 Entscheid): andere Rollen können Positionen anlegen, aber den aufgelösten Preis nicht übersteuern. Die Server-Action validiert „gesetzter Preis == Resolver-Preis", sonst403 PRICE_OVERRIDE_DENIED. - Begründungspflicht: jeder Override schreibt
audit_log(context={reason, resolver_price, override_price}). - Backend nimmt keinen Preis ungeprüft (Abkehr von IST BR-50): die Server-Action vergleicht immer gegen den Resolver und erzwingt das Recht.
In Dok 30 §13 (RLS-Matrix) ist quote_lines-UPDATE für OPS entsprechend auf „ohne Preis-Override" einzuschränken bzw. die Permission PRICE_OVERRIDE als Fussnote zu führen.