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ätRolle
quotesKopf einer Offerte (Kontakt, Gültigkeitsdatum, Rabatt Total, Status, Affiliate-Code, Versandmeta)
quote_linesEinzelpositionen (Produkt, Menge, Einheitspreis aus Preisliste, optionaler Zeilenrabatt)
box_productsProduktkatalog (Modul 3, geteilt) — Box-/Fass-/Behältertypen
price_list_entriesEffektiv-datierte Preislisten je Produkt (Modul 3) — Offertenmodul liest nur
referral_codesAffiliate-/Rabattcodes (Modul 7) — Offertenmodul liest und verknüpft
affiliatesAffiliate-Stamm (Modul 7) — über referral_codes verbunden
ordersAuftragsmodul (Modul 5) — wird bei ACCEPTED erzeugt
invoicesRechnungen (Modul 6) — wird bei ACCEPTED erzeugt
commission_entriesProvisions-Eintrag (Modul 7) — entsteht pending bei Konversion (ACCEPTED → CONVERTED) + Code; Freigabe (approved) bei DELIVERED (#19)
contactsKunden-/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.

CodeDEESEN
DRAFTEntwurfBorradorDraft
SENTVersendetEnviadaSent
ACCEPTEDAkzeptiertAceptadaAccepted
REJECTEDAbgelehntRechazadaRejected
EXPIREDAbgelaufenVencidaExpired
CONVERTEDUmgewandeltConvertidaConverted

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 (pendingapproved bei DELIVEREDpaid).

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)

  1. Benutzer (admin/ops) öffnet „Neue Offerte" — Kontakt wählen (Suche in contacts) oder Inline-Schnellerfassung wenn noch nicht im Stamm.
  2. Gültigkeitsdatum setzen (Default: heute + 14 Tage, konfigurierbar via Settings).
  3. Positionen hinzufügen: Produkt aus Katalog wählen → aktuell gültige Preisliste wird automatisch gezogen (price_list_entries WHERE effective_from ≤ today AND (effective_to IS NULL OR effective_to ≥ today) ORDER BY effective_from DESC LIMIT 1). Zone/Zielort auswählbar wenn preisrelevant.
  4. Menge eingeben (inputmode numeric, Bottom-Sheet-Picker am Handy); Zeilenrabatt optional (Prozent oder absolut).
  5. Optional: Gesamtrabatt (Prozent oder absolut) auf Summenlevel.
  6. Optional: Affiliate-/Rabattcode eingeben → System prüft referral_codes auf Gültigkeit (active=true, Datum), zeigt den Rabattbetrag live an und verbucht ihn als Kundenrabatt in discount_from_code_chf (separater Posten, mindert grand_total_chf, auf PDF ausgewiesen — O-08, Entscheid 2026-06-28). Snapshot der Code-Regel (discount_from_code_type/value) wird gespeichert.
  7. Interne Notizen und Kundennotizen (erscheinen auf PDF) ergänzen.
  8. Beträge werden client-seitig kalkuliert und beim Speichern server-seitig validiert und in quotes.subtotal_chf / discount_total_chf / grand_total_chf persistiert.
  9. 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)

  1. „Versenden" öffnet Versand-Dialog: Kanal wählen (WhatsApp / E-Mail / Beide).
  2. PDF wird serverseitig generiert (Next.js Server Action, Bibliothek: 🔲 zu bestätigen — Kandidaten: @react-pdf/renderer oder 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.
  3. PDF wird in Supabase Storage abgelegt (quotes/{quote_id}/QUO-YYYY-NNNN.pdf); quotes.pdf_storage_path wird gesetzt.
  4. 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.
  5. E-Mail: Resend (bereits eingerichtet, RESEND_FROM = @dominicanoexpress.com) sendet E-Mail mit PDF-Anhang an contacts.email. Template mehrsprachig (DE/ES/EN nach contacts.preferred_lang).
  6. Status → SENT; sent_at = now(); sent_via Array 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_via wird ergänzt.

4.3 Status-Übergänge (manuell + automatisch)

VonNachAuslöserAktion
DRAFTSENTVersand-Aktion (§4.2)PDF erzeugen, versenden
DRAFTREJECTEDmanuell (Kontakt sagt ab)kein Follow-up
SENTACCEPTEDmanuell (Kontakt sagt zu)→ §4.4 Konversion
SENTREJECTEDmanuellkein Follow-up
SENTEXPIREDCron-Job täglich (valid_until < today, status = SENT)automatisch
ACCEPTEDCONVERTEDServer Action nach Auftragsanlageorder_id setzen
JederDRAFTDuplikate-/Kopier-Funktion → neue Offerteneue ID, Status DRAFT

4.4 Offerte akzeptieren → Konversion (ACCEPTED → CONVERTED)

  1. Benutzer klickt „Akzeptieren" (nur admin/ops).
  2. Server Action (transaktional): a. quotes.statusACCEPTED. b. Neuen Auftrag anlegen: orders.INSERT mit quote_id (FK), customer_id, status = OPEN. Keine Kopie der Positionen in eine order_lines-Tabelle (F-01) — die Positionen bleiben im (eingefrorenen) quote_lines der Offerte; die Boxen werden in Modul 5 direkt aus quote_lines materialisiert (je Position N boxes mit Produkt-Snapshot über box_product_id). c. Rechnung anlegen: invoices.INSERT mit order_id, contact_id, total_chf = quotes.grand_total_chf, status = OPEN. Diese Rechnung bucht über das Auto-Posting (invoice_issuedINVOICE) die Forderung ins Ledger — die Offerten-Annahme selbst bucht keine RECEIVABLE (F-06, keine Doppelzählung). d. Wenn referral_code_id IS NOT NULL: referral_code_id + affiliate_id in den Auftrag übernehmen und den commission_entries-Eintrag (Modul 7) mit status='pending' anlegen (#19 — Entstehung bei Konversion, Modul 16 §4.5 A). Die Freigabe/Fälligkeit (status → 'approved') erfolgt später automatisch beim Tracking-DELIVERED der Sendung (Modul 5 / Modul 16 §4.5 B) — nicht bei invoices.status → 'PAID'. e. quotes.statusCONVERTED; quotes.order_id setzen.
  3. Toast-Bestätigung mit Links zu Auftrag und Rechnung.
  4. 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 nicht CONVERTED); Fehlermeldung zeigt, welcher Schritt fehlschlug.
  • Auftrag wurde bereits manuell angelegt (Duplicate-Guard): orders hat Unique-Constraint auf quote_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 in internal_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, Status DRAFT, issue_date = today, valid_until = today + 14.
  • Positionen, Rabatte und Notizen werden übernommen; sent_at, pdf_storage_path, order_id werden 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.

FormfaktorPattern
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.

FormfaktorPattern
HandyWizard/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).
TabletEinseitiges Formular: Kopfdaten oben (Kontakt, Datum), Positionen-Tabelle (inline editierbar mit TanStack), Rabatt-Panel rechts, Versand-Panel unten. Kein Wizard nötig.
DesktopIdentisch 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.

BereichInhalt
HeaderQuote-Nummer, Kontaktname (Link zum CRM), Status-Badge, Aktionsleiste
Aktionsleiste„Versenden", „Akzeptieren", „Ablehnen", „Kopieren", „PDF herunterladen" — je nach aktuellem Status sichtbar/deaktiviert
PositionenTabelle/Karten (adaptiv wie Liste)
Rabatt-ZusammenfassungZwischensumme, Zeilenrabatte, Gesamtrabatt, Affiliate-Rabatt, Endbetrag
Versand-HistoryWann / Kanal versendet
IntegrationenLink 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.

FormfaktorPattern
HandyBottom-Sheet (volle Höhe)
Tablet/DesktopModal-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_idcontacts.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 auf QUOTE_SENT gesetzt werden (🔲 zu bestätigen, ob gewünscht).

6.2 Modul 3 — Produkte & Preise

  • quote_lines.product_idbox_products.id.
  • quote_lines.price_list_entry_idprice_list_entries.id (Snapshot zum Erstellungszeitpunkt).
  • Preislisten-Abfrage: beim Hinzufügen einer Position wird die aktuell gültige price_list_entry für product_id + optional zone abgefragt.
  • Snapshot (product_snapshot JSONB) sichert Produktnamen und Preis für spätere Anzeige unabhängig von Katalogänderungen.

6.3 Modul 5 — Logistik/ERP

  • Bei ACCEPTEDCONVERTED: orders.INSERT mit quote_id (FK 1:1, Unique-Constraint).
  • Keine order_lines-Tabelle (F-01): Die Positionen bleiben im eingefrorenen quote_lines der konvertierten Offerte (orders.quote_idquotesquote_lines). Modul 5 materialisiert die Boxen direkt aus quote_lines (pro Position Anzahl × Box-Typ → N boxes mit Produkt-Snapshot via box_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 ACCEPTEDCONVERTED: 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 bucht INVOICE_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 ins movements-Ledger — Auslöser ist die Rechnung (Finanzmodul).

6.5 Modul 7 — Affiliate-Programm

  • quotes.referral_code_idreferral_codes.id: Affiliate-Code wird auf Offerte gespeichert; der Code-Rabatt ist ein Kundenrabatt (mindert grand_total_chf, auf PDF ausgewiesen — siehe O-08).
  • Bei CONVERTED: commission_entries.INSERT mit status='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

EventAuslöserEmpfänger-Modul
quote.sentStatus → SENT(optional) CRM: Lead-Status-Update
quote.acceptedStatus → ACCEPTEDModul 5: Order anlegen; Modul 6: Invoice anlegen
quote.convertedStatus → CONVERTEDModul 7: commission_entries (pending) anlegen (#19; Freigabe später bei DELIVERED)
quote.expiredCron-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

SituationBehandlung
Produkt aus Katalog entfernt, Offerte existiert nochproduct_snapshot JSONB sichert Anzeige; box_products hat ON DELETE RESTRICT → Produkt kann nur deaktiviert, nicht gelöscht werden
Preislistenänderung nach OffertenerstellungSnapshot in quote_lines.unit_price_chf + product_snapshot bleibt; „Preise aktualisieren"-Button in DRAFT erlaubt explizites Neuziehen
Doppelter Affiliate-Code auf zwei gleichzeitigen OffertenErlaubt; Commission wird pro Offerte/Auftrag berechnet
Affiliate-Code-Rabatt + Gesamt-RabattBeide 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 RabattenValidierungsfehler: Endbetrag darf nicht negativ werden; Rabatt wird auf maximum des Subtotals gecapped
Offerte in falscher Sprache versendetcontacts.preferred_lang steuert Sprache des PDF + E-Mail-Templates; Benutzer kann Sprache im Versand-Dialog übersteuern
Resend-API-Fehler beim VersendenToast-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 fehlGanzer 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 ACCEPTED oder CONVERTED dürfen nicht verändert oder gelöscht werden. RLS-Policy erlaubt UPDATE nur bei status IN ('DRAFT', 'SENT') (clientseitig); serverseitig zusätzliche Guard-Prüfung in der Server Action.
  • Das audit_log protokolliert jeden Status-Übergang, jeden Versand und die Konversion mit user_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)

  • quotes und quote_lines enthalten Personendaten (über contact_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_percent und quotes.vat_amount_chf sind im Schema vorhanden, initial NULL.
  • 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)

#PunktKontext
✅ O-01Rabatt-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-02PDF-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-03Lead-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-04Gültigkeitsdauer Default: 14 Tage vorgeschlagen — entspricht das der geschäftlichen Praxis? Saisonal variabel?Settings-Tabelle kann Default halten
🔲 O-05Kamera-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-06Mehrsprachiges 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-07MWST-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-08Affiliate-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-09Offerten-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-10Expired-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_chf ist der Default. Ein Abweichen (quote_lines.unit_price_chf ≠ aufgelöster Preis) ist ein privilegierter Vorgang.
  • Recht PRICE_OVERRIDE (Default ADMIN, optional OPERATIONS — 🔲 Entscheid): andere Rollen können Positionen anlegen, aber den aufgelösten Preis nicht übersteuern. Die Server-Action validiert „gesetzter Preis == Resolver-Preis", sonst 403 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.