Compare commits
115 Commits
2246bdf2be
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f647409f | ||
|
|
5d3eed874c | ||
|
|
3a090b7664 | ||
|
|
e2d92a031f | ||
|
|
6b9023c871 | ||
|
|
f783a98392 | ||
|
|
d5593900fb | ||
|
|
92eb7454dc | ||
|
|
cb4787e595 | ||
|
|
3fb80e6404 | ||
|
|
a318a5e225 | ||
|
|
1b49348577 | ||
|
|
fb61f5f396 | ||
|
|
7119383139 | ||
|
|
fa93e060c2 | ||
|
|
be4e9204a3 | ||
|
|
2e30974aed | ||
|
|
c30505de05 | ||
|
|
5a2afd8600 | ||
|
|
72b906779c | ||
|
|
0b4755d438 | ||
|
|
d37a6b4348 | ||
|
|
21550f1389 | ||
|
|
43e6a92663 | ||
|
|
305671acc6 | ||
|
|
17d76f55e6 | ||
|
|
4e82146ab7 | ||
|
|
83a019785b | ||
|
|
f091b97ca0 | ||
|
|
6d6bb4dfe6 | ||
|
|
e59039dd4e | ||
|
|
f80866218b | ||
|
|
545b5e93bf | ||
|
|
e9117d34a0 | ||
|
|
d72830412e | ||
|
|
0a9997e98a | ||
|
|
0b268881a1 | ||
|
|
993c817dff | ||
|
|
4da386f693 | ||
|
|
c252fa11d0 | ||
|
|
1a7a765d72 | ||
|
|
ec1ad4d444 | ||
|
|
0679fdfdd7 | ||
|
|
0ecb16daff | ||
|
|
c8183abff7 | ||
|
|
0435984c82 | ||
|
|
27fe6c6eef | ||
|
|
bc6487bba4 | ||
|
|
d8b7076e7e | ||
|
|
9d888e6380 | ||
|
|
2f92213594 | ||
|
|
4fa2daf212 | ||
|
|
dc5465e289 | ||
|
|
99cbb11c5a | ||
|
|
a95afbf443 | ||
|
|
cd3fba2113 | ||
|
|
6821b4e8d9 | ||
|
|
a3c5a66d3f | ||
|
|
2038c4df3e | ||
|
|
90a5e17f2b | ||
|
|
3b16722fca | ||
|
|
1e7112fa3c | ||
|
|
db95e435c2 | ||
|
|
c3f97f8dca | ||
|
|
98b1301e96 | ||
|
|
387ba19802 | ||
|
|
2c572c5bd4 | ||
|
|
ae4a8429c7 | ||
|
|
58f946490c | ||
|
|
5cd78854e3 | ||
|
|
fb9b522e74 | ||
|
|
c7a4b2a9c9 | ||
|
|
5b03de6b7e | ||
|
|
100ad254cb | ||
|
|
d122c957eb | ||
|
|
a93fb975c0 | ||
|
|
21c4aa859e | ||
|
|
20f7d5a05a | ||
|
|
2b92626bf9 | ||
|
|
8bd47905b0 | ||
|
|
309dc33acd | ||
|
|
d1cc6b5d5f | ||
|
|
13981970f5 | ||
|
|
8dd5fd3f44 | ||
|
|
ac4416f7ab | ||
|
|
992e726406 | ||
|
|
988625ba42 | ||
|
|
0621a0be16 | ||
|
|
926650d3b6 | ||
|
|
cb12a6ced4 | ||
|
|
66039eb4cf | ||
|
|
5eef6c76ef | ||
|
|
f71fc7f88d | ||
|
|
60e64c689d | ||
|
|
bd7d5dd1b2 | ||
|
|
54b9dea70f | ||
|
|
6b35917a06 | ||
|
|
567d1f226b | ||
|
|
215da5f25b | ||
|
|
d05274700a | ||
|
|
16917bcbdc | ||
|
|
915d3b1b2f | ||
|
|
4ef21ea144 | ||
|
|
071e669b71 | ||
|
|
07c7d8c656 | ||
|
|
bffaca1d63 | ||
|
|
cc7445e4c5 | ||
|
|
00d1be3e4f | ||
|
|
98c030cd82 | ||
|
|
8e91b3e810 | ||
|
|
dd5d1295a7 | ||
|
|
cce9812bbc | ||
|
|
e00acac626 | ||
|
|
0ec1319753 | ||
|
|
057012e09f |
15
.agents/skills/grand-functions/SKILL.md
Normal file
15
.agents/skills/grand-functions/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: grand-functions
|
||||
description: Dokumentation und Spezifikation des CASPOS Webshops inklusive Datenmodell, Kernprozessen und Sicherheitskonzept.
|
||||
---
|
||||
|
||||
# Grand Functions - CASPOS Webshop Spezifikation
|
||||
|
||||
Dieser Skill enthält die vollständige Dokumentation und Spezifikation des CASPOS Webshops. Die Spezifikationen sind in logische Referenzdokumente unterteilt:
|
||||
|
||||
- [Projekt-Überblick & Tech-Stack](file:///c:/source/webshop/.agents/skills/grand-functions/references/overviews.md)
|
||||
- [Datenmodell & Schema](file:///c:/source/webshop/.agents/skills/grand-functions/references/schema.md)
|
||||
- [Kernprozesse](file:///c:/source/webshop/.agents/skills/grand-functions/references/processes.md)
|
||||
- [Sicherheitskonzept (RLS)](file:///c:/source/webshop/.agents/skills/grand-functions/references/security.md)
|
||||
- [Testumgebung & -abdeckung](file:///c:/source/webshop/.agents/skills/grand-functions/references/testing.md)
|
||||
|
||||
16
.agents/skills/grand-functions/references/overviews.md
Normal file
16
.agents/skills/grand-functions/references/overviews.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Projekt-Überblick & Technologie-Stack
|
||||
|
||||
## 1. Projekt-Überblick
|
||||
|
||||
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technologie-Stack
|
||||
|
||||
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
|
||||
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
|
||||
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
|
||||
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
|
||||
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
|
||||
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
|
||||
75
.agents/skills/grand-functions/references/processes.md
Normal file
75
.agents/skills/grand-functions/references/processes.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Kernprozesse
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
|
||||
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
|
||||
B -- Ja --> D[Wizard öffnen / Konfigurieren]
|
||||
D --> E[Endkunden auswählen/anlegen]
|
||||
E --> F[Produkte & Module wählen]
|
||||
F --> G{Validierung erfolgreich?}
|
||||
G -- Nein --> H[Validierungsfehler anzeigen]
|
||||
G -- Ja --> I[Bestellung absenden]
|
||||
I --> J[Snapshot einfrieren & in DB speichern]
|
||||
J --> K[Rechnungs-PDF generieren & in Storage laden]
|
||||
K --> L[E-Mail mit PDF an Partner senden]
|
||||
L --> M[Bestellung in der Übersicht anzeigen]
|
||||
```
|
||||
|
||||
### A. Partner- & Unternehmens-Hierarchie
|
||||
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
|
||||
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
|
||||
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
|
||||
|
||||
### B. Endkunden-Verwaltung (`/my-customers`)
|
||||
- **Firmenweite Sicht**: Da `end_customers.partner_id` on `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
|
||||
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
|
||||
|
||||
### C. Bestell-Wizard & Validierung (`/order`)
|
||||
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
|
||||
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
|
||||
- **Top-Down State-Management**:
|
||||
- Die Wahl eines Basis-Produkts steuert die Sichtbarkeit und Wählbarkeit aller Module (Top-Down). Modul-Auswahlen dürfen niemals die Liste der wählbaren Basis-Produkte beeinflussen (Verhinderung von UI-Deadlocks).
|
||||
- **Auto-Reset & Kaskaden-Bereinigung**: Beim Wechsel des Basis-Produkts in `selectProduct` werden inkompatible Module in anderen Kategorien automatisch über eine Kaskaden-Bereinigung (Filterung der `moduleIds` basierend auf den `exclusions` des neuen Produkts) deselektiert.
|
||||
- **Sichtbarkeits- & Wählbarkeits-Garantie**: Basis-Produkte werden im UI niemals durch Modulausschlüsse ausgeblendet. Nachgelagerte Produkte/Module, die mit dem ausgewählten Basis-Produkt inkompatibel sind, erhalten im JSX das `disabled`-Attribut und sind nicht anklickbar (Sperrung von "Kein Backoffice" bei "Small Business"). Klicks auf Module haben keinerlei Rückwirkung auf die Basis-Produkt-Auswahl.
|
||||
- **Radio-Button-Verhalten (allow_multiselect = false)**: Falls eine Kategorie keine Mehrfachauswahl erlaubt, wird bei Auswahl eines neuen Moduls das zuvor ausgewählte Modul dieser Kategorie automatisch abgewählt.
|
||||
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
|
||||
|
||||
### D. Bestellungs-Verwaltung & Statusübergang
|
||||
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
|
||||
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
|
||||
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
|
||||
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
|
||||
|
||||
---
|
||||
|
||||
### E. Multi-Kassen-Warenkorb & Checkout-Split
|
||||
- **Endpunkt / Server Action**: `POST /api/orders/checkout` sowie Server Action `checkoutAction` in `app/actions/checkout.ts`
|
||||
- **Funktionsweise**:
|
||||
- Empfängt ein Array von `items` (Kassenkonfigurationen).
|
||||
- Validiert Auth und Partner-Firma Zuweisung.
|
||||
- Generiert einen `order_hash` zur Vermeidung von Doppelübermittlungen (Idempotenz-Guard).
|
||||
- Teilt die Konfigurationen nach `billingInterval` / `billingType` auf (Kauf vs. Abo).
|
||||
- Erstellt separate Orders in der Datenbank:
|
||||
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
|
||||
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
|
||||
- Friert für jede Order einen konsolidierten `order_data` (JSONB) und `customer_data` (JSONB) ein.
|
||||
- Triggert die PDF-Erstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
|
||||
|
||||
---
|
||||
|
||||
### F. PDF-Rechnungsgenerierung & Mail-Versand (Post-Processing)
|
||||
- **Entkopplung & Asynchronität**: Die Generierung der PDFs und der E-Mail-Versand sind vollständig aus dem synchronen Checkout-Flow entkoppelt. Der Checkout liefert dem Frontend sofort nach Speicherung in der DB eine Erfolgsmeldung zurück. Die Generierung und der Versand werden asynchron im Hintergrund (via unblockiertem Promise-Worker) ausgeführt, um SMTP-Latenzen abzufangen.
|
||||
- **Snapshot-Exklusivität**: Die PDF-Generierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
|
||||
- **Layout-Unterscheidung**:
|
||||
- **Typ `purchase` (Kauf)**:
|
||||
- Generiert eine klassische **Anfragebestätigung Kauf**.
|
||||
- Weist die einmalige Gesamtsumme aus.
|
||||
- **Typ `subscription` (Abonnement)**:
|
||||
- Generiert eine **Anfragebestätigung Abonnement**.
|
||||
- Weist monatlich wiederkehrende Kosten aus.
|
||||
- **Archivierung & Benachrichtigung**:
|
||||
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
||||
- E-Mail-Versand mit PDF-Anhang an den Partner.
|
||||
|
||||
|
||||
97
.agents/skills/grand-functions/references/schema.md
Normal file
97
.agents/skills/grand-functions/references/schema.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Datenmodell & Beziehungen (Schema)
|
||||
|
||||
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
|
||||
### `companies` (Unternehmen)
|
||||
- Repräsentiert die Partner-Unternehmen (Retailer).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `street`, `zip`, `city`, `email` (Adressdaten)
|
||||
|
||||
### `users` (Systembenutzer)
|
||||
- Erweitert die Authentifizierungsdaten aus `auth.users`.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
|
||||
### `end_customers` (Endkunden)
|
||||
- Die Endkunden, für die die Partner Lizenzen bestellen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
|
||||
- `company_name` (TEXT)
|
||||
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
|
||||
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
|
||||
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
|
||||
|
||||
### `categories` (Kategorien)
|
||||
- Steuert das Layout und Verhalten im Wizard.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
|
||||
- `allow_multiselect` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
||||
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
- `preselect` (BOOLEAN)
|
||||
- `resets_others` (BOOLEAN)
|
||||
|
||||
### `products` (Katalogprodukte / Basis-Editionen)
|
||||
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `category_id` (UUID, References `categories`)
|
||||
- `name` (TEXT)
|
||||
- `base_price` (DECIMAL)
|
||||
- `tax_rate` (DECIMAL)
|
||||
- `billing_interval` (TEXT: `'one_time'` / `'monthly'`)
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
- `allow_update_discount` (BOOLEAN) <-- *Berechtigung für Update-Rabatt*
|
||||
|
||||
### `modules` (Zusatzmodule / Erweiterungen)
|
||||
- Optionale Erweiterungen für Produkte. Repräsentiert im TypeScript-Code durch das Interface `ProductModule`.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `product_id` (UUID, References `products`)
|
||||
- `category_id` (UUID, References `categories`)
|
||||
- `name` (TEXT)
|
||||
- `price` (DECIMAL)
|
||||
- `has_quantity` (BOOLEAN)
|
||||
|
||||
### `global_inclusions` (Globale automatische Beigaben)
|
||||
- Regelt, welche Module bei Auswahl eines Produkts kostenlos enthalten sind.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `trigger_product_id` (UUID, References `products`)
|
||||
- `included_module_id` (UUID, References `modules`)
|
||||
|
||||
### `global_exclusions` (Globale Ausschlüsse)
|
||||
- Regelt Inkompatibilitäten zwischen Produkten und/oder Modulen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `product_id` (UUID, References `products`)
|
||||
- `excluded_product_id` (UUID, References `products`, optional)
|
||||
- `excluded_module_id` (UUID, References `modules`, optional)
|
||||
|
||||
### `orders` (Bestellungen / Anfragen)
|
||||
- Gespeicherte Snapshots von Konfigurationen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_id` (UUID, References `auth.users(id)`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
- `end_customer_id` (UUID, References `end_customers(id)`)
|
||||
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
|
||||
- `order_hash` (TEXT, Idempotenz-Guard)
|
||||
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
|
||||
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_data` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
|
||||
- `order_data` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
|
||||
- `pdf_url` (TEXT)
|
||||
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
||||
- `created_at` (TIMESTAMPTZ)
|
||||
|
||||
### `settings` (Systemeinstellungen)
|
||||
- Globale Shopeinstellungen (SMTP und LicServer).
|
||||
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
|
||||
- `host` (TEXT)
|
||||
- `port` (INTEGER)
|
||||
- `secure` (BOOLEAN)
|
||||
- `user` (TEXT)
|
||||
- `pass` (TEXT)
|
||||
- `licserver_base_url` (TEXT)
|
||||
- `licserver_api_key` (TEXT)
|
||||
- `updated_at` (TIMESTAMPTZ)
|
||||
20
.agents/skills/grand-functions/references/security.md
Normal file
20
.agents/skills/grand-functions/references/security.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sicherheitskonzept (RLS - Row Level Security)
|
||||
|
||||
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
|
||||
|
||||
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
|
||||
- **Endkunden (`end_customers`)**:
|
||||
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
|
||||
- **Bestellungen (`orders`)**:
|
||||
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
|
||||
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
|
||||
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
|
||||
- **Lizenzen (`licenses`)**:
|
||||
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
|
||||
|
||||
- **Systembenutzer (`users`) & Rollen-Schutz**:
|
||||
- Authentifizierte Nutzer dürfen nur ihr eigenes Profil lesen.
|
||||
- Admins dürfen alle Benutzer lesen. Zur Vermeidung von unendlichen RLS-Rekursionen auf der Tabelle `users` wird die Admin-Zuweisung über die Security-Definer-Funktion `public.is_admin(user_id)` geprüft, welche RLS auf Datenbank-Ebene umgeht.
|
||||
- Rollenschutz-Trigger (`check_user_role_escalation`): Jegliche Änderung der Spalte `role` oder Zuweisung der Rolle `'admin'` ist auf API-Ebene für normale Nutzer gesperrt. Updates/Inserts der Rolle werden ausschließlich von der `service_role` (Backend Admin-Client) akzeptiert, um Privilegien-Eskalation zu verhindern.
|
||||
22
.agents/skills/grand-functions/references/testing.md
Normal file
22
.agents/skills/grand-functions/references/testing.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Testumgebung & Testabdeckung
|
||||
|
||||
## 1. Test-Setup
|
||||
- **Framework**: Vitest (Version ^1.6.0)
|
||||
- **Konfiguration**: [vitest.config.ts](file:///c:/source/webshop/shop/vitest.config.ts)
|
||||
- **Befehl**: `npm run test` (führt `vitest run` aus)
|
||||
|
||||
---
|
||||
|
||||
## 2. Test-Szenarien
|
||||
|
||||
### A. Frontend Wizard State-Management
|
||||
Getestet in [wizard-state.test.ts](file:///c:/source/webshop/shop/lib/wizard-state.test.ts):
|
||||
- **Auto-Reset bei Produktwechsel**: Prüft, ob nachgelagerte Module, die durch `global_exclusions` für das neue Produkt gesperrt sind, automatisch aus der Selektion fliegen.
|
||||
- **Radio-Button-Verhalten (allow_multiselect = false)**: Stellt sicher, dass in Single-Select-Kategorien nur maximal ein Modul aktiv ist und bei Auswahl eines anderen Moduls das vorherige automatisch abgewählt wird.
|
||||
|
||||
### B. Backend Checkout-Split
|
||||
Getestet in [checkout-split.test.ts](file:///c:/source/webshop/shop/lib/checkout-split.test.ts):
|
||||
- **Array-Validierung & Split (basketItems)**: Stellt sicher, dass das vollständige Array an konfigurierten Kassen (`basketItems`) validiert und verarbeitet wird (nicht nur ein Single-Produkt).
|
||||
- **Kauf-Warenkorb**: Verifiziert, dass ein reiner Kauf-Warenkorb (`one_time`/`purchase`) exakt eine Order erzeugt.
|
||||
- **Abo-Warenkorb**: Verifiziert, dass ein reiner Abo-Warenkorb (`monthly`/`subscription`) exakt eine Order erzeugt.
|
||||
- **Gemischter Warenkorb**: Stellt sicher, dass ein gemischter Warenkorb korrekt in zwei separate Bestell-Gruppen aufgeteilt wird (Kauf-Items & Abo-Items separat).
|
||||
@@ -33,4 +33,9 @@ python3 .agents/skills/uiux-designer/scripts/search.py "<keywords>" --design-sys
|
||||
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --domain <domain>
|
||||
|
||||
### Step 4: Stack Guidelines
|
||||
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
|
||||
python3 .agents/skills/uiux-designer/scripts/search.py "<keyword>" --stack html-tailwind
|
||||
|
||||
## Anti-AI-Design Guidelines
|
||||
|
||||
- **NO Centered Badges/Pills**: Do NOT place centered badges or pills (e.g. "CASPOS Lizenz-Portal" or "Ablauf") above main headings. They look like generic, low-quality AI-generated templates.
|
||||
- **NO Text Gradients**: Avoid text gradients (e.g. `bg-gradient-to-r ... bg-clip-text text-transparent`) on headings. Use flat colors (like `text-white` or solid brand colors).
|
||||
144
.agentsspec
144
.agentsspec
@@ -1,144 +0,0 @@
|
||||
# Projekt-Dokumentation: CASPOS Webshop
|
||||
|
||||
Diese Datei beschreibt die Architektur, das Datenmodell, die Kernprozesse und das Sicherheitskonzept des CASPOS Webshops.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projekt-Überblick
|
||||
|
||||
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technologie-Stack
|
||||
|
||||
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
|
||||
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
|
||||
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
|
||||
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
|
||||
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
|
||||
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenmodell & Beziehungen (Schema)
|
||||
|
||||
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
|
||||
### `companies` (Unternehmen)
|
||||
- Repräsentiert die Partner-Unternehmen (Retailer).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `street`, `zip`, `city`, `email` (Adressdaten)
|
||||
|
||||
### `users` (Systembenutzer)
|
||||
- Erweitert die Authentifizierungsdaten aus `auth.users`.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
|
||||
### `profiles` (Benutzerprofile)
|
||||
- Stammdaten der einzelnen Benutzer (Ansprechpartner).
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `first_name`, `last_name`, `email` etc.
|
||||
|
||||
### `end_customers` (Endkunden)
|
||||
- Die Endkunden, für die die Partner Lizenzen bestellen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
|
||||
- `company_name` (TEXT)
|
||||
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
|
||||
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
|
||||
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
|
||||
|
||||
### `products` (Katalogprodukte)
|
||||
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
||||
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
|
||||
- `requirements` (UUID[] von anderen Produkten)
|
||||
- `exclusions` (UUID[] von inkompatiblen Produkten)
|
||||
|
||||
### `product_modules` (Zusatzmodule)
|
||||
- Optionale Erweiterungen für Produkte.
|
||||
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
|
||||
- `requirements` (UUID[] von Modulen)
|
||||
- `exclusions` (UUID[] von Modulen)
|
||||
|
||||
### `orders` (Bestellungen / Anfragen)
|
||||
- Gespeicherte Snapshots von Konfigurationen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_id` (UUID, References `auth.users(id)`) <-- *Ersteller der Bestellung*
|
||||
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
|
||||
- `order_hash` (TEXT, Idempotenz-Guard gegen Doppelübermittlung)
|
||||
- `end_customer_id` (UUID, References `end_customers(id)`)
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_data` (JSONB) <-- *Eingefrorener Snapshot des Endkunden*
|
||||
- `order_data` (JSONB) <-- *Eingefrorener Snapshot der Produktkonfiguration*
|
||||
- `pdf_url` (TEXT, Link zur PDF-Rechnung im Storage)
|
||||
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
||||
|
||||
---
|
||||
|
||||
## 4. Kernprozesse
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
|
||||
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
|
||||
B -- Ja --> D[Wizard öffnen / Konfigurieren]
|
||||
D --> E[Endkunden auswählen/anlegen]
|
||||
E --> F[Produkte & Module wählen]
|
||||
F --> G{Validierung erfolgreich?}
|
||||
G -- Nein --> H[Validierungsfehler anzeigen]
|
||||
G -- Ja --> I[Bestellung absenden]
|
||||
I --> J[Snapshot einfrieren & in DB speichern]
|
||||
J --> K[Rechnungs-PDF generieren & in Storage laden]
|
||||
K --> L[E-Mail mit PDF an Partner senden]
|
||||
L --> M[Bestellung in der Übersicht anzeigen]
|
||||
```
|
||||
|
||||
### A. Partner- & Unternehmens-Hierarchie
|
||||
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
|
||||
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
|
||||
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
|
||||
|
||||
### B. Endkunden-Verwaltung (`/my-customers`)
|
||||
- **Firmenweite Sicht**: Da `end_customers.partner_id` auf `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
|
||||
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
|
||||
|
||||
### C. Bestell-Wizard & Validierung (`/order`)
|
||||
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
|
||||
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
|
||||
- **Ausschlüsse (Exclusions)**: Inkompatible Produkte oder Module blockieren sich gegenseitig.
|
||||
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
|
||||
|
||||
### D. Bestellungs-Verwaltung & Statusübergang
|
||||
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
|
||||
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
|
||||
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
|
||||
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Sicherheitskonzept (RLS - Row Level Security)
|
||||
|
||||
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
|
||||
|
||||
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
|
||||
- **Endkunden (`end_customers`)**:
|
||||
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
|
||||
- **Bestellungen (`orders`)**:
|
||||
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
|
||||
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
|
||||
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
|
||||
- **Lizenzen (`licenses`)**:
|
||||
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
|
||||
|
||||
---
|
||||
|
||||
## Kommentarbereich
|
||||
*Bitte nutze diesen Bereich oder füge Zeilenkommentare hinzu, um Änderungen oder Ergänzungen vorzuschlagen.*
|
||||
|
||||
- **Kommentar 1**:
|
||||
- **Kommentar 2**:
|
||||
@@ -8,3 +8,12 @@
|
||||
- Die ungültige RLS-Policy auf `public.partners` wurde entfernt.
|
||||
- `public.companies` wird stattdessen korrekt durch `Admin manage companies` geschützt.
|
||||
- **Consequences**: Behebt Migrations-Fehler im Staging-Server, CI/CD-Pipeline baut wieder fehlerfrei.
|
||||
|
||||
## ADR-002: Entfernung ungenutzter Lizenztabellen
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-07-06
|
||||
- **Context**: Die Tabellen `pos_modules` und `licenses` wurden von einem alten bzw. externen Service genutzt. Im aktuellen Webshop-Frontend werden sie nicht benötigt und führen zu unnötiger RLS- und Integritäts-Komplexität.
|
||||
- **Decision**:
|
||||
- Erstellen einer SQL-Migration zum Löschen (`DROP`) von `licenses` und `pos_modules`.
|
||||
- Anpassung der Integritätsprüfungs-Funktionen `check_database_integrity` und `repair_database_schema` zur Entfernung dieser Relationen.
|
||||
- **Consequences**: Schlankere Datenbankstruktur, RLS-Komplexität verringert und sauberere Codebasis.
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
- [Supabase Schema](file:///c:/source/webshop/.brain/supabase-schema.md)
|
||||
- [ADR Log](file:///c:/source/webshop/.brain/adr-log.md)
|
||||
- [LicServer API Spezifikation](file:///c:/source/webshop/.brain/licserver-api.md)
|
||||
|
||||
227
.brain/licserver-api.md
Normal file
227
.brain/licserver-api.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# LicServer API Spezifikation (v1)
|
||||
|
||||
Dokumentation der REST-Schnittstellen des CASPOS Lizenzservers.
|
||||
|
||||
## 1. Endpunkte (Port 9981 - Activation API)
|
||||
|
||||
### Activation (Aktivierung)
|
||||
|
||||
#### `POST /v1/activate`
|
||||
Aktiviert eine neue Lizenz.
|
||||
|
||||
**Request Body (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"productId": "string",
|
||||
"productVersion": "string",
|
||||
"activationCode": "string",
|
||||
"hardwareBindingType": "string",
|
||||
"hardwareBindingId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"licenseData": {
|
||||
"id": "string",
|
||||
"serialNumber": "string",
|
||||
"productId": "string",
|
||||
"filename": "string",
|
||||
"content": "string" // Base64 Byte-Inhalt
|
||||
},
|
||||
"apiKey": "string",
|
||||
"secret": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Headers:**
|
||||
- `ETag` (string): Der ETag-Wert der Lizenz.
|
||||
|
||||
---
|
||||
|
||||
#### `POST /v1/activate-existing`
|
||||
Aktiviert eine bereits vorhandene Lizenz erneut.
|
||||
|
||||
**Request Body (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"productId": "string",
|
||||
"productVersion": "string",
|
||||
"serialNumber": "string",
|
||||
"proofOfPossessionHash": "string",
|
||||
"salt": "string",
|
||||
"hardwareBindingType": "string",
|
||||
"hardwareBindingId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
*Gleiche Struktur wie bei `/v1/activate`.*
|
||||
|
||||
---
|
||||
|
||||
### Licenses (Lizenzen)
|
||||
|
||||
#### `GET /v1/licenses/{id}`
|
||||
Holt die Lizenz-Details zu einer bestimmten Lizenz-ID.
|
||||
|
||||
**Request Parameters:**
|
||||
- `id` (path, string, required): Die UUID der Lizenz.
|
||||
- `If-None-Match` (header, string, optional): ETag zur Cache-Validierung.
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"serialNumber": "string",
|
||||
"productId": "string",
|
||||
"filename": "string",
|
||||
"content": "string" // Base64 Byte-Inhalt
|
||||
}
|
||||
```
|
||||
|
||||
**Response `304 Not Modified`:**
|
||||
Falls die Lizenz nicht verändert wurde.
|
||||
|
||||
**Response `404 Not Found` / `403 Forbidden`:**
|
||||
Standard Fehlerobjekt `ProblemDetails`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpunkte (Port 9980 - Management API)
|
||||
|
||||
Alle Anfragen an die Management-API müssen authentifiziert sein.
|
||||
- **Header-Format:** `X-Api-Key: <Schlüssel>`
|
||||
|
||||
### Partners (Partner)
|
||||
|
||||
#### `GET /api-v1/partners`
|
||||
Gibt eine paginierte Liste aller Partner zurück.
|
||||
|
||||
**Request Parameters (Query):**
|
||||
- `search` (string, optional): Filtert nach Name/E-Mail.
|
||||
- `page` (integer, default: 1)
|
||||
- `pageSize` (integer, default: 20)
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"totalCount": 1,
|
||||
"totalPages": 1,
|
||||
"hasNextPage": false,
|
||||
"hasPreviousPage": false,
|
||||
"items": [
|
||||
{
|
||||
"id": "string (UUID)",
|
||||
"name": "string",
|
||||
"email": "string",
|
||||
"erpId": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api-v1/partners/{id}`
|
||||
Holt Partner-Details zu einer bestimmten ID.
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"id": "string (UUID)",
|
||||
"name": "string",
|
||||
"email": "string",
|
||||
"erpId": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Customers (Kunden von Partnern)
|
||||
|
||||
#### `GET /api-v1/partners/{partnerId}/customers`
|
||||
Gibt eine paginierte Liste aller Kunden eines bestimmten Partners zurück.
|
||||
|
||||
**Request Parameters (Query):**
|
||||
- `search` (string, optional): Filtert nach Name/E-Mail.
|
||||
- `page` (integer, default: 1)
|
||||
- `pageSize` (integer, default: 20)
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"totalCount": 1,
|
||||
"totalPages": 1,
|
||||
"hasNextPage": false,
|
||||
"hasPreviousPage": false,
|
||||
"items": [
|
||||
{
|
||||
"id": "string (UUID)",
|
||||
"name": "string",
|
||||
"street": "string",
|
||||
"houseNumber": "string",
|
||||
"houseNumberAddon": "string",
|
||||
"zipCode": "string",
|
||||
"city": "string",
|
||||
"countryCode": "string",
|
||||
"email": "string",
|
||||
"partnerId": "string (UUID)",
|
||||
"erpId": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api-v1/partners/{partnerId}/customers/{customerId}`
|
||||
Holt detaillierte Kundeninformationen.
|
||||
|
||||
**Response `200 OK` (`application/json`):**
|
||||
```json
|
||||
{
|
||||
"id": "string (UUID)",
|
||||
"name": "string",
|
||||
"street": "string",
|
||||
"houseNumber": "string",
|
||||
"houseNumberAddon": "string",
|
||||
"zipCode": "string",
|
||||
"city": "string",
|
||||
"countryCode": "string",
|
||||
"email": "string",
|
||||
"partnerId": "string (UUID)",
|
||||
"erpId": "string",
|
||||
"partnerName": "string",
|
||||
"taxNumber": "string",
|
||||
"vatIdNumber": "string",
|
||||
"notes": "string",
|
||||
"locations": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Typen & Schemas
|
||||
|
||||
### `LicenseData`
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"serialNumber": "string",
|
||||
"productId": "string",
|
||||
"filename": "string",
|
||||
"content": "string" // Base64 Byte-Inhalt
|
||||
}
|
||||
```
|
||||
|
||||
### `ProblemDetails`
|
||||
```json
|
||||
{
|
||||
"type": "string",
|
||||
"title": "string",
|
||||
"status": 0,
|
||||
"detail": "string",
|
||||
"instance": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,3 +46,19 @@
|
||||
- `order_data` (JSONB)
|
||||
- `pdf_url` (TEXT)
|
||||
- `status` (TEXT)
|
||||
|
||||
## `categories` (Kategorien)
|
||||
- Steuert das Layout und Verhalten im Wizard.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `is_required` (BOOLEAN)
|
||||
- `allow_multiselect` (BOOLEAN)
|
||||
- `sort_order` (INTEGER)
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
|
||||
## `settings` (Systemeinstellungen)
|
||||
- Globale Shopeinstellungen (SMTP & Lizenzserver).
|
||||
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
|
||||
- `host`, `port`, `secure`, `user`, `pass` (SMTP-Konfiguration)
|
||||
- `licserver_base_url`, `licserver_api_key` (CASPOS Lizenzserver-Einstellungen)
|
||||
- `updated_at` (TIMESTAMPTZ)
|
||||
|
||||
@@ -2,3 +2,7 @@
|
||||
# https://app.supabase.com/project/_/settings/api
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
|
||||
|
||||
# CASPOS LicServer (internal container network — server-side only, never expose to browser)
|
||||
LICSERVER_BASE_URL=http://192.168.178.174:9980
|
||||
LICSERVER_API_KEY=your-licserver-api-key
|
||||
|
||||
314
shop/app/actions/checkout.ts
Normal file
314
shop/app/actions/checkout.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createHash } from 'crypto';
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
|
||||
import { getProducts, getCategories } from '@/lib/actions/products';
|
||||
import { sendMail } from '@/utils/mail';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
const rand = Math.floor(10000 + Math.random() * 90000);
|
||||
return `${prefix}-${year}-${rand}`;
|
||||
}
|
||||
|
||||
function hashOrderPayload(endCustomerId: string, items: any[]): string {
|
||||
const str = endCustomerId + JSON.stringify(items);
|
||||
return createHash('sha256').update(str).digest('hex');
|
||||
}
|
||||
|
||||
// Asynchroner Hintergrund-Worker für Post-Processing (PDF-Erstellung und E-Mail)
|
||||
async function triggerPostProcessing(orderId: string, supabase: any, customerSnapshot: any, orderSnapshot: any, type: string, email: string) {
|
||||
try {
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (error || !order) {
|
||||
console.error(`Post-Processing failed: Order ${orderId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF generieren
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
})
|
||||
);
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
|
||||
|
||||
if (!uploadError) {
|
||||
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
|
||||
} else {
|
||||
console.error(`PDF Upload failed for order ${orderId}:`, uploadError);
|
||||
}
|
||||
|
||||
// E-Mail versenden
|
||||
if (email) {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19;
|
||||
const items = orderSnapshot.items ?? [];
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
let totalDetailsText = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
}
|
||||
|
||||
let totalDetailsHtml = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items);
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: `Anfragebestätigung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error in post-processing for order ${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkoutAction(params: {
|
||||
items: any[];
|
||||
endCustomerId: string;
|
||||
customerProfile?: any;
|
||||
}) {
|
||||
const { items, endCustomerId, customerProfile } = params;
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. Session & Auth Check
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.');
|
||||
}
|
||||
|
||||
// 2. Idempotency Guard
|
||||
const orderHash = hashOrderPayload(endCustomerId, items);
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
|
||||
const { data: existingOrders } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('order_hash', orderHash)
|
||||
.gte('created_at', fiveMinutesAgo);
|
||||
|
||||
if (existingOrders && existingOrders.length > 0) {
|
||||
return { success: true, orderIds: existingOrders.map(o => o.id), duplicated: true };
|
||||
}
|
||||
|
||||
// Fetch End Customer Stammdaten
|
||||
const { data: endCustomer, error: customerErr } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.eq('id', endCustomerId)
|
||||
.single();
|
||||
|
||||
if (customerErr || !endCustomer) {
|
||||
throw new Error('Endkunde nicht gefunden.');
|
||||
}
|
||||
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile || {}, endCustomer);
|
||||
|
||||
const products = await getProducts();
|
||||
const categories = await getCategories();
|
||||
|
||||
// 3. Checkout Split
|
||||
const purchaseItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||
);
|
||||
const subscriptionItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
|
||||
);
|
||||
|
||||
const orderIds: string[] = [];
|
||||
|
||||
const createOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
|
||||
if (groupItems.length === 0) return;
|
||||
|
||||
const orderItemsList: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
item.selections,
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
items: orderItemsList,
|
||||
total: total,
|
||||
tax_rate: 19,
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
};
|
||||
|
||||
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId,
|
||||
total_price: total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
type: type,
|
||||
payment_method: paymentMethod,
|
||||
status: 'pending'
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) throw orderError;
|
||||
orderIds.push(order.id);
|
||||
|
||||
// Entkoppeltes Post-Processing (asynchroner Aufruf)
|
||||
if (user.email) {
|
||||
Promise.resolve().then(() => {
|
||||
triggerPostProcessing(order.id, supabase, customerSnapshot, orderSnapshot, type, user.email!);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await createOrderGroup(purchaseItems, 'purchase', 'invoice');
|
||||
await createOrderGroup(subscriptionItems, 'subscription', 'sepa');
|
||||
|
||||
return { success: true, orderIds, duplicated: false };
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users } from 'lucide-react'
|
||||
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
|
||||
import { getCompanies, createCompany, updateCompany, deleteCompany, syncCompaniesFromLicServer, syncCustomersFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export default function CompaniesPage() {
|
||||
const [companies, setCompanies] = useState<any[]>([])
|
||||
@@ -32,6 +32,28 @@ export default function CompaniesPage() {
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null)
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('')
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
setStatusMsg(null)
|
||||
try {
|
||||
const resCompanies = await syncCompaniesFromLicServer()
|
||||
const resCustomers = await syncCustomersFromLicServer()
|
||||
setStatusMsg(`${resCompanies.count} Partner und ${resCustomers.count} Kunden erfolgreich vom LicServer importiert.`)
|
||||
setStatusType('success')
|
||||
loadCompanies()
|
||||
} catch (err: any) {
|
||||
setStatusMsg(`Fehler beim Import: ${err.message || err}`)
|
||||
setStatusType('error')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -90,18 +112,7 @@ export default function CompaniesPage() {
|
||||
|
||||
|
||||
|
||||
const handleDelete = async (id: string, firmName: string) => {
|
||||
if (!confirm(`Firma "${firmName}" wirklich löschen? Alle zugewiesenen Benutzer werden getrennt.`)) return
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteCompany(id)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const filteredCompanies = companies.filter((c) => {
|
||||
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -123,14 +134,34 @@ export default function CompaniesPage() {
|
||||
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
variant="outline"
|
||||
className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white"
|
||||
>
|
||||
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Partner importieren
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Firmen-Tabelle */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||
<div className="relative mb-6 max-w-sm">
|
||||
@@ -214,11 +245,14 @@ export default function CompaniesPage() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={deletingId === company.id}
|
||||
onClick={() => handleDelete(company.id, company.name)}
|
||||
onClick={() => {
|
||||
setDeleteConfirmId(company.id)
|
||||
setDeleteConfirmName(company.name)
|
||||
}}
|
||||
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="Firma löschen"
|
||||
>
|
||||
@@ -329,6 +363,44 @@ export default function CompaniesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog: Delete Confirmation */}
|
||||
<Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
|
||||
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Firma löschen</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||
Möchten Sie die Firma <strong>{deleteConfirmName}</strong> wirklich löschen? Alle zugewiesenen Benutzer werden getrennt. Dieser Schritt kann nicht rückgängig gemacht werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (deleteConfirmId) {
|
||||
setDeletingId(deleteConfirmId)
|
||||
const targetId = deleteConfirmId
|
||||
setDeleteConfirmId(null)
|
||||
try {
|
||||
await deleteCompany(targetId)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white font-semibold"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, Upload, Database, AlertTriangle, Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AdminSettings() {
|
||||
@@ -16,26 +19,58 @@ export default function AdminSettings() {
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// LicServer Config State
|
||||
const [licUrl, setLicUrl] = useState('');
|
||||
const [licKey, setLicKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [licSaving, setLicSaving] = useState(false);
|
||||
const [licTesting, setLicTesting] = useState(false);
|
||||
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [licMsg, setLicMsg] = useState('');
|
||||
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAccess() {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
if (!userData || userData.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
} else {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userData || userData.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
|
||||
// Load current LicServer config from DB
|
||||
try {
|
||||
const { data: licRow } = await supabase
|
||||
.from('settings')
|
||||
.select('licserver_base_url, licserver_api_key')
|
||||
.eq('id', 'licserver')
|
||||
.single();
|
||||
if (licRow) {
|
||||
setLicUrl(licRow.licserver_base_url || '');
|
||||
setLicKey(licRow.licserver_api_key || '');
|
||||
}
|
||||
} catch (dbErr) {
|
||||
console.error("Fehler beim Laden der LicServer-Einstellungen:", dbErr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler bei checkAccess in Einstellungen:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +90,7 @@ export default function AdminSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/db-backup');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -149,11 +184,10 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
<div className={`p-4 rounded-lg text-sm border ${statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
@@ -170,8 +204,8 @@ export default function AdminSettings() {
|
||||
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
@@ -200,9 +234,9 @@ export default function AdminSettings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
disabled={exporting || importing}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
@@ -216,8 +250,8 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 dark:border-white/10"
|
||||
@@ -232,6 +266,125 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LicServer Konfiguration */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-violet-500" />
|
||||
LicServer Konfiguration
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Verbindungseinstellungen zum CASPOS Lizenzserver.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{licMsg && (
|
||||
<div className={`p-3 rounded-lg text-sm border ${licMsgType === 'success'
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400'
|
||||
: 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{licMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Test Result */}
|
||||
{licStatus && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm border ${licStatus.ok
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||
}`}>
|
||||
{licStatus.ok
|
||||
? <CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
: <XCircle className="w-4 h-4 shrink-0" />}
|
||||
{licStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-url" className="text-xs font-medium flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="lic-url"
|
||||
value={licUrl}
|
||||
onChange={e => setLicUrl(e.target.value)}
|
||||
placeholder="http://192.168.178.174:9980"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-key" className="text-xs font-medium flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="lic-key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={licKey}
|
||||
onChange={e => setLicKey(e.target.value)}
|
||||
placeholder="Ihr X-Api-Key"
|
||||
className="text-sm font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(v => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button
|
||||
id="lic-save-btn"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicSaving(true);
|
||||
setLicMsg('');
|
||||
setLicStatus(null);
|
||||
const res = await saveLicServerConfig(licUrl, licKey);
|
||||
setLicMsgType(res.success ? 'success' : 'error');
|
||||
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
|
||||
setLicSaving(false);
|
||||
}}
|
||||
className="bg-violet-600 hover:bg-violet-500 text-white"
|
||||
>
|
||||
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
id="lic-test-btn"
|
||||
variant="outline"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicTesting(true);
|
||||
setLicStatus(null);
|
||||
// Save first, then test
|
||||
await saveLicServerConfig(licUrl, licKey);
|
||||
const result = await testLicServerConnection();
|
||||
setLicStatus(result);
|
||||
setLicTesting(false);
|
||||
}}
|
||||
className="border-white/10"
|
||||
>
|
||||
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2 } from 'lucide-react'
|
||||
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2, Sparkles } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
|
||||
import { DemoWrapper } from '@/components/DemoWrapper'
|
||||
import { AdminNavLink } from '@/components/admin/admin-nav-link'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -39,55 +40,60 @@ export default async function AdminLayout({
|
||||
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<nav className="flex-1 px-4 space-y-2 py-4">
|
||||
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<AdminNavLink href="/admin">
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/products">
|
||||
<Package className="w-5 h-5" />
|
||||
Produkte
|
||||
</Link>
|
||||
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/categories">
|
||||
<LayoutGrid className="w-5 h-5" />
|
||||
Kategorien
|
||||
</Link>
|
||||
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/wysiwyg">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Order-Wizard
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/orders">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Bestellungen
|
||||
</Link>
|
||||
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/users">
|
||||
<Users className="w-5 h-5" />
|
||||
Benutzer
|
||||
</Link>
|
||||
</AdminNavLink>
|
||||
|
||||
<Link href="/admin/companies" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<AdminNavLink href="/admin/companies">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Firmen
|
||||
</Link>
|
||||
</AdminNavLink>
|
||||
|
||||
{!isVerwaltung && (
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
|
||||
</div>
|
||||
<Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<AdminNavLink href="/admin/einstellungen">
|
||||
<Settings className="w-5 h-5" />
|
||||
Allgemein
|
||||
</Link>
|
||||
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/settings">
|
||||
<Wrench className="w-5 h-5" />
|
||||
SMTP
|
||||
</Link>
|
||||
<Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/tools">
|
||||
<Database className="w-5 h-5" />
|
||||
DB-Tools
|
||||
</Link>
|
||||
</AdminNavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
|
||||
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">
|
||||
<AdminLogoutButton />
|
||||
<div className="px-3 pt-2 flex items-center gap-2 border-t border-slate-200 dark:border-white/5 opacity-60">
|
||||
|
||||
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Product, ProductModule } from '@/lib/types'
|
||||
import { Plus, Search, Package, Layers } from 'lucide-react'
|
||||
|
||||
interface DependencyPopoverProps {
|
||||
currentProductId: string
|
||||
assignedIds: string[]
|
||||
allProducts: Product[]
|
||||
allModules: ProductModule[]
|
||||
onAddDependency: (id: string) => void
|
||||
}
|
||||
|
||||
export function DependencyPopover({
|
||||
currentProductId,
|
||||
assignedIds,
|
||||
allProducts,
|
||||
allModules,
|
||||
onAddDependency
|
||||
}: DependencyPopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Filter out the current product, and items already assigned
|
||||
const otherProducts = allProducts.filter(p => p.id !== currentProductId && !assignedIds.includes(p.id))
|
||||
const otherModules = allModules.filter(m => m.product_id !== currentProductId && !assignedIds.includes(m.id))
|
||||
|
||||
// Apply search
|
||||
const filteredProducts = otherProducts.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
|
||||
const filteredModules = otherModules.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-all text-slate-400 hover:text-white"
|
||||
title="Abhängigkeit hinzufügen"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-2 z-50 w-72 bg-slate-900 border border-white/10 rounded-xl shadow-2xl p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Suchen nach Produkt/Modul..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{filteredProducts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Produkte</span>
|
||||
{filteredProducts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
onAddDependency(p.id)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||
>
|
||||
<Package className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||
<span className="text-xs text-white group-hover:text-primary transition-colors">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredModules.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Module</span>
|
||||
{filteredModules.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onAddDependency(m.id)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||
>
|
||||
<Layers className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||
<span className="text-xs text-white group-hover:text-primary transition-colors">{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProducts.length === 0 && filteredModules.length === 0 && (
|
||||
<div className="text-[11px] text-slate-400 text-center py-4">
|
||||
Keine Ergebnisse gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
81
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineInputProps {
|
||||
value: string
|
||||
onSave: (val: string) => Promise<void>
|
||||
className?: string
|
||||
type?: 'text' | 'number'
|
||||
}
|
||||
|
||||
export function InlineInput({ value, onSave, className, type = 'text' }: InlineInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [currentValue, setCurrentValue] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleBlur = async () => {
|
||||
setIsEditing(false)
|
||||
const trimmed = currentValue.trim()
|
||||
|
||||
if (type === 'number') {
|
||||
const parsed = parseFloat(trimmed)
|
||||
if (isNaN(parsed) || trimmed === '') {
|
||||
setCurrentValue(value)
|
||||
return
|
||||
}
|
||||
const finalVal = parsed.toString()
|
||||
if (finalVal !== value) {
|
||||
await onSave(finalVal)
|
||||
}
|
||||
} else {
|
||||
if (trimmed === '') {
|
||||
setCurrentValue(value)
|
||||
return
|
||||
}
|
||||
if (trimmed !== value) {
|
||||
await onSave(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBlur()
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
step={type === 'number' ? '0.01' : undefined}
|
||||
value={currentValue}
|
||||
onChange={(e) => setCurrentValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`bg-white/10 text-white border border-primary px-1 rounded outline-none w-full ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={`cursor-pointer hover:bg-white/5 rounded px-1 transition ${className}`}
|
||||
>
|
||||
{value || <span className="italic text-slate-500">Klicken zum Bearbeiten</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
118
shop/app/admin/wysiwyg/components/module-popover.tsx
Normal file
118
shop/app/admin/wysiwyg/components/module-popover.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ProductModule } from '@/lib/types'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
|
||||
interface ModulePopoverProps {
|
||||
assignedModules: ProductModule[]
|
||||
allModules: ProductModule[]
|
||||
onAddModule: (module: ProductModule) => void
|
||||
}
|
||||
|
||||
export function ModulePopover({ assignedModules, allModules, onAddModule }: ModulePopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Filter modules that are not already assigned to this product
|
||||
// We match by name to treat identical modules across products as the same entity
|
||||
const availableModules = allModules.filter(m => {
|
||||
const isAssigned = assignedModules.some(am => am.name.toLowerCase() === m.name.toLowerCase())
|
||||
const matchesSearch = m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(m.description && m.description.toLowerCase().includes(search.toLowerCase()))
|
||||
return !isAssigned && matchesSearch
|
||||
})
|
||||
|
||||
// Deduplicate available modules by name to show a clean list
|
||||
const uniqueAvailableModules: ProductModule[] = []
|
||||
const seenNames = new Set<string>()
|
||||
for (const m of availableModules) {
|
||||
const nameKey = m.name.toLowerCase()
|
||||
if (!seenNames.has(nameKey)) {
|
||||
seenNames.add(nameKey)
|
||||
uniqueAvailableModules.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-primary/10 text-primary border border-primary/20 hover:bg-primary hover:text-black rounded-lg transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Modul hinzufügen
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-2 z-50 w-72 bg-slate-900 border border-white/10 rounded-xl shadow-2xl p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Modul suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
|
||||
{uniqueAvailableModules.length > 0 ? (
|
||||
uniqueAvailableModules.map((mod) => (
|
||||
<button
|
||||
key={mod.id}
|
||||
onClick={() => {
|
||||
onAddModule(mod)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-2 hover:bg-white/5 rounded-lg transition-colors group"
|
||||
>
|
||||
<div className="font-medium text-xs text-white group-hover:text-primary transition-colors">
|
||||
{mod.name}
|
||||
</div>
|
||||
{mod.description && (
|
||||
<div className="text-[10px] text-slate-400 line-clamp-1 mt-0.5">
|
||||
{mod.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-primary font-mono mt-1">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-slate-400 text-center py-4">
|
||||
Keine passenden Module gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
shop/app/admin/wysiwyg/page.tsx
Normal file
48
shop/app/admin/wysiwyg/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { WysiwygAdminClient } from './wysiwyg-client'
|
||||
import { Edit, Sparkles } from 'lucide-react'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function WysiwygAdminPage() {
|
||||
return (
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<Edit className="w-8 h-8 text-primary" />
|
||||
WYSIWYG Live-Editor
|
||||
</h2>
|
||||
<p className="text-slate-300">
|
||||
Kategorien und Produkte visuell im Wizard-Layout bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
|
||||
<WysiwygDataWrapper />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
async function WysiwygDataWrapper() {
|
||||
const products = await getProducts().catch(() => [])
|
||||
const categories = await getCategories().catch(() => [])
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data: allModules } = await supabase
|
||||
.from('product_modules')
|
||||
.select('*')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
return (
|
||||
<WysiwygAdminClient
|
||||
initialProducts={products}
|
||||
initialCategories={categories}
|
||||
allModules={allModules || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
450
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
450
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Category, Product, ProductModule } from '@/lib/types'
|
||||
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory, addProductDependency, removeProductDependency } from '@/lib/actions/products'
|
||||
import { InlineInput } from './components/inline-input'
|
||||
import { ModulePopover } from './components/module-popover'
|
||||
import { DependencyPopover } from './components/dependency-popover'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
export function WysiwygAdminClient({
|
||||
initialCategories,
|
||||
initialProducts,
|
||||
allModules
|
||||
}: {
|
||||
initialCategories: Category[]
|
||||
initialProducts: Product[]
|
||||
allModules: ProductModule[]
|
||||
}) {
|
||||
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
|
||||
const [categories, setCategories] = useState(initialCategories)
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDeleteCategory = async (catId: string) => {
|
||||
// Cascade-Check: check if there are products belonging to this category
|
||||
const categoryProducts = products.filter(p => p.category_id === catId)
|
||||
if (categoryProducts.length > 0) {
|
||||
alert(`Diese Kategorie enthält noch ${categoryProducts.length} Produkte. Bitte lösche oder verschiebe diese zuerst!`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm('Möchten Sie diese Kategorie wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteCategory(catId)
|
||||
setCategories(categories.filter(c => c.id !== catId))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('Fehler beim Löschen der Kategorie.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteProduct = async (prodId: string) => {
|
||||
if (!window.confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteProduct(prodId)
|
||||
setProducts(products.filter(p => p.id !== prodId))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('Fehler beim Löschen des Produkts.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getDependencyName = (depId: string) => {
|
||||
const matchedProduct = products.find(p => p.id === depId)
|
||||
if (matchedProduct) return matchedProduct.name
|
||||
const matchedModule = allModules.find(m => m.id === depId)
|
||||
if (matchedModule) return `${matchedModule.name} (Modul)`
|
||||
return depId
|
||||
}
|
||||
|
||||
// Filterung der Kategorien nach aktivem Modus
|
||||
const filteredCategories = categories.filter(cat =>
|
||||
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
|
||||
)
|
||||
|
||||
// Filterung der Produkte nach aktivem Modus
|
||||
const filteredProducts = products.filter(p =>
|
||||
mode === 'purchase' ? p.billing_interval === 'one_time' : p.billing_interval === 'monthly'
|
||||
)
|
||||
|
||||
const handleAddCategory = async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newCat = await createCategory({
|
||||
name: 'Neue Kategorie',
|
||||
description: 'Beschreibung hier...',
|
||||
sort_order: categories.length + 1,
|
||||
is_required: false,
|
||||
allow_multiselect: false,
|
||||
free_items_limit: 0,
|
||||
preselect: false,
|
||||
show_in_kauf: mode === 'purchase',
|
||||
show_in_abo: mode === 'subscription',
|
||||
resets_others: false
|
||||
})
|
||||
setCategories([...categories, newCat])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddProduct = async (categoryId: string) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newProd = await createProduct({
|
||||
name: 'Neues Produkt',
|
||||
description: 'Produktbeschreibung...',
|
||||
base_price: 19.99,
|
||||
tax_rate: 19,
|
||||
billing_interval: mode === 'purchase' ? 'one_time' : 'monthly',
|
||||
category_id: categoryId,
|
||||
show_in_kauf: mode === 'purchase',
|
||||
show_in_abo: mode === 'subscription'
|
||||
}, [])
|
||||
setProducts([...products, newProd])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCategoryToggle = async (catId: string, fields: Partial<Category>) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c))
|
||||
await updateCategory(catId, fields)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-6 max-w-7xl mx-auto space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
<div className="flex justify-between items-center border-b border-white/10 pb-4">
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => setMode('purchase')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
mode === 'purchase' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
Kauf-Modus (Einmalig)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('subscription')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
mode === 'subscription' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
Abo-Modus (Monatlich)
|
||||
</button>
|
||||
</div>
|
||||
{isPending && <span className="text-xs text-primary animate-pulse font-medium">Speichere Änderungen...</span>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{filteredCategories
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(cat => {
|
||||
const catProducts = filteredProducts.filter(p => p.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 shadow-xl">
|
||||
<div className="flex justify-between items-start border-b border-white/5 pb-4">
|
||||
<div className="w-full">
|
||||
<InlineInput
|
||||
value={cat.name}
|
||||
onSave={async (newName) => {
|
||||
await updateCategory(cat.id, { name: newName })
|
||||
setCategories(categories.map(c => c.id === cat.id ? { ...c, name: newName } : c))
|
||||
}}
|
||||
className="text-2xl font-bold text-white block w-full"
|
||||
/>
|
||||
<InlineInput
|
||||
value={cat.description || ''}
|
||||
onSave={async (newDesc) => {
|
||||
await updateCategory(cat.id, { description: newDesc })
|
||||
setCategories(categories.map(c => c.id === cat.id ? { ...c, description: newDesc } : c))
|
||||
}}
|
||||
className="text-slate-400 text-sm mt-1 block w-full"
|
||||
/>
|
||||
|
||||
{/* Kategorie Attribute (Toggles) */}
|
||||
<div className="flex flex-wrap items-center gap-6 mt-3 text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.is_required}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Pflichtauswahl</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.allow_multiselect}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked
|
||||
handleCategoryToggle(cat.id, {
|
||||
allow_multiselect: checked,
|
||||
...(checked ? { preselect: false } : {})
|
||||
})
|
||||
}}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Mehrfachauswahl</span>
|
||||
</label>
|
||||
{!cat.allow_multiselect && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.preselect}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { preselect: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Vorauswahl</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.resets_others}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { resets_others: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Rest zurücksetzen</span>
|
||||
</label>
|
||||
{cat.allow_multiselect && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Freie Artikel Limit:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cat.free_items_limit}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { free_items_limit: parseInt(e.target.value) || 0 })}
|
||||
className="w-12 rounded border-white/10 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 px-1 py-0.5 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(cat.id)}
|
||||
className="p-2 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded-lg transition-all ml-4 flex-shrink-0"
|
||||
title="Kategorie löschen"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
{catProducts.map(prod => (
|
||||
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 relative group flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<InlineInput
|
||||
value={prod.name}
|
||||
onSave={async (newName) => {
|
||||
await updateProduct(prod.id, { name: newName }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, name: newName } : p))
|
||||
}}
|
||||
className="font-semibold text-white block"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(prod.id)}
|
||||
className="p-1 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||||
title="Produkt löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<InlineInput
|
||||
value={prod.description || ''}
|
||||
onSave={async (newDesc) => {
|
||||
await updateProduct(prod.id, { description: newDesc }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, description: newDesc } : p))
|
||||
}}
|
||||
className="text-xs text-slate-400 block"
|
||||
/>
|
||||
<div className="text-primary font-mono text-sm flex items-center space-x-1">
|
||||
<InlineInput
|
||||
value={prod.base_price.toString()}
|
||||
type="number"
|
||||
onSave={async (newPrice) => {
|
||||
const priceNum = parseFloat(newPrice) || 0
|
||||
await updateProduct(prod.id, { base_price: priceNum }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, base_price: priceNum } : p))
|
||||
}}
|
||||
className="inline-block"
|
||||
/>
|
||||
<span>€</span>
|
||||
</div>
|
||||
|
||||
{/* Modul-Badges */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Module:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.modules || []).length > 0 ? (
|
||||
(prod.modules || []).map((m) => (
|
||||
<span key={m.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-medium">
|
||||
{m.name}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const updatedModules = (prod.modules || []).filter(mod => mod.id !== m.id)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules } : p))
|
||||
await updateProduct(prod.id, {}, updatedModules)
|
||||
}}
|
||||
className="text-slate-500 hover:text-white font-bold transition-colors ml-0.5"
|
||||
title="Modul entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Module</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Benötigt:</span>
|
||||
<DependencyPopover
|
||||
currentProductId={prod.id}
|
||||
assignedIds={prod.requirements || []}
|
||||
allProducts={products}
|
||||
allModules={allModules}
|
||||
onAddDependency={async (depId) => {
|
||||
const newReqs = [...(prod.requirements || []), depId]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||||
await addProductDependency(prod.id, 'requirements', depId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.requirements || []).length > 0 ? (
|
||||
(prod.requirements || []).map((reqId) => (
|
||||
<span key={reqId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-green-500/10 text-green-400 border border-green-500/20 font-medium animate-fade-in">
|
||||
{getDependencyName(reqId)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newReqs = (prod.requirements || []).filter(id => id !== reqId)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||||
await removeProductDependency(prod.id, 'requirements', reqId)
|
||||
}}
|
||||
className="text-green-600 hover:text-green-400 font-bold transition-colors ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Anforderungen</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exclusions */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Schließt aus:</span>
|
||||
<DependencyPopover
|
||||
currentProductId={prod.id}
|
||||
assignedIds={prod.exclusions || []}
|
||||
allProducts={products}
|
||||
allModules={allModules}
|
||||
onAddDependency={async (depId) => {
|
||||
const newExcs = [...(prod.exclusions || []), depId]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||||
await addProductDependency(prod.id, 'exclusions', depId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.exclusions || []).length > 0 ? (
|
||||
(prod.exclusions || []).map((exId) => (
|
||||
<span key={exId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium animate-fade-in">
|
||||
{getDependencyName(exId)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newExcs = (prod.exclusions || []).filter(id => id !== exId)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||||
await removeProductDependency(prod.id, 'exclusions', exId)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-400 font-bold transition-colors ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Ausschlüsse</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modul hinzufügen Popover */}
|
||||
<div className="mt-3 pt-2 border-t border-white/5">
|
||||
<ModulePopover
|
||||
assignedModules={prod.modules || []}
|
||||
allModules={allModules}
|
||||
onAddModule={async (mod) => {
|
||||
const newMod = {
|
||||
name: mod.name,
|
||||
description: mod.description,
|
||||
price: mod.price,
|
||||
requirements: mod.requirements || [],
|
||||
exclusions: mod.exclusions || [],
|
||||
has_quantity: mod.has_quantity || false
|
||||
}
|
||||
const updatedModules = [...(prod.modules || []), newMod]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules as any } : p))
|
||||
await updateProduct(prod.id, {}, updatedModules)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => handleAddProduct(cat.id)}
|
||||
className="flex flex-col items-center justify-center p-4 border border-dashed border-white/20 hover:border-primary/50 rounded-xl text-slate-400 hover:text-primary transition h-full min-h-[140px]"
|
||||
>
|
||||
<span className="text-2xl mb-1">+</span>
|
||||
<span className="text-xs font-medium">Produkt hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={handleAddCategory}
|
||||
className="w-full py-4 border border-dashed border-white/20 hover:border-primary/50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary transition"
|
||||
>
|
||||
<span className="text-xl mr-2">+</span>
|
||||
<span className="font-medium">Neue Kategorie hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
shop/app/api/cron/sync-companies/route.ts
Normal file
25
shop/app/api/cron/sync-companies/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { syncCompaniesFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Check authorization header
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// Secure the cron endpoint in production
|
||||
if (process.env.NODE_ENV === 'production' && cronSecret) {
|
||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await syncCompaniesFromLicServer()
|
||||
return NextResponse.json({ success: true, count: res.count })
|
||||
} catch (err: any) {
|
||||
console.error('[cron] Sync failed:', err)
|
||||
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
25
shop/app/api/cron/sync-customers/route.ts
Normal file
25
shop/app/api/cron/sync-customers/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { syncCustomersFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Check authorization header
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// Secure the cron endpoint in production
|
||||
if (process.env.NODE_ENV === 'production' && cronSecret) {
|
||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await syncCustomersFromLicServer()
|
||||
return NextResponse.json({ success: true, count: res.count })
|
||||
} catch (err: any) {
|
||||
console.error('[cron] Customer sync failed:', err)
|
||||
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal file
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { CustomerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners/[id]/customers
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners/{id}/customers
|
||||
* [id] must be a valid UUID.
|
||||
* Query params forwarded: search, page, pageSize
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Basic UUID format guard
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Forward query params ───────────────────────────────────────────────
|
||||
const { searchParams } = req.nextUrl
|
||||
const upstream = new URL(`${cfg.base_url}/api-v1/partners/${id}/customers`)
|
||||
const search = searchParams.get('search')
|
||||
const page = searchParams.get('page')
|
||||
const pageSize = searchParams.get('pageSize')
|
||||
if (search) upstream.searchParams.set('search', search)
|
||||
if (page) upstream.searchParams.set('page', page)
|
||||
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(
|
||||
upstream.toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: CustomerDtoPagedResult = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partner/:id/customers fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
74
shop/app/api/licserver/partners/[id]/route.ts
Normal file
74
shop/app/api/licserver/partners/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners/[id]
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners/{id}
|
||||
* [id] must be a valid UUID.
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Basic UUID format guard
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cfg.base_url}/api-v1/partners/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: PartnerDto = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partner/:id fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
shop/app/api/licserver/partners/route.ts
Normal file
71
shop/app/api/licserver/partners/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners
|
||||
* Query params forwarded: search, page, pageSize
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Forward query params ───────────────────────────────────────────────
|
||||
const { searchParams } = req.nextUrl
|
||||
const upstream = new URL(`${cfg.base_url}/api-v1/partners`)
|
||||
const search = searchParams.get('search')
|
||||
const page = searchParams.get('page')
|
||||
const pageSize = searchParams.get('pageSize')
|
||||
if (search) upstream.searchParams.set('search', search)
|
||||
if (page) upstream.searchParams.set('page', page)
|
||||
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(upstream.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
// Internal container network — short timeout
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: PartnerDtoPagedResult = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partners fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
297
shop/app/api/orders/checkout/route.ts
Normal file
297
shop/app/api/orders/checkout/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { getProducts, getCategories } from '@/lib/actions/products';
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
|
||||
import { sendMail } from '@/utils/mail';
|
||||
import { createHash } from 'crypto';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
||||
import { validateWizardSelections } from '@/lib/actions/validation';
|
||||
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
const rand = Math.floor(10000 + Math.random() * 90000);
|
||||
return `${prefix}-${year}-${rand}`;
|
||||
}
|
||||
|
||||
function hashOrderSnapshot(snapshot: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { items, customerProfile, endCustomerId, endCustomer } = body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const products = await getProducts();
|
||||
const categories = await getCategories();
|
||||
|
||||
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
|
||||
for (const item of items) {
|
||||
if (item.selections) {
|
||||
validateWizardSelections(item.selections, products, categories);
|
||||
}
|
||||
}
|
||||
|
||||
// Split items into purchase and subscription
|
||||
const purchaseItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||
);
|
||||
const subscriptionItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
|
||||
);
|
||||
|
||||
const createdOrders: any[] = [];
|
||||
|
||||
const processOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
|
||||
if (groupItems.length === 0) return;
|
||||
|
||||
// Build consolidated snapshots
|
||||
// For the multi-item support, we combine the order snapshots of each item in the group
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer);
|
||||
|
||||
const orderItemsList: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
item.selections,
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
items: orderItemsList,
|
||||
total: total,
|
||||
tax_rate: 19, // default
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
};
|
||||
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot);
|
||||
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId || null,
|
||||
total_price: total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
type: type,
|
||||
payment_method: paymentMethod,
|
||||
status: 'pending'
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) throw orderError;
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
|
||||
// Trigger PDF generation & storage upload
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
})
|
||||
);
|
||||
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
|
||||
|
||||
if (!uploadError) {
|
||||
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
|
||||
order.pdf_url = fileName;
|
||||
}
|
||||
} catch (pdfErr) {
|
||||
console.error('PDF error for order ' + order.id, pdfErr);
|
||||
}
|
||||
|
||||
// Trigger E-Mail notifications
|
||||
if (user.email) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19;
|
||||
const items = orderSnapshot.items ?? [];
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
let totalDetailsText = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
}
|
||||
|
||||
let totalDetailsHtml = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items);
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
|
||||
|
||||
const { data: bufferData, error: downloadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.download(fileName);
|
||||
|
||||
const attachments: any[] = [];
|
||||
if (!downloadError && bufferData) {
|
||||
const buffer = Buffer.from(await bufferData.arrayBuffer());
|
||||
attachments.push({
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Anfragebestätigung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments
|
||||
});
|
||||
} catch (mailErr) {
|
||||
console.error('Mail error for order ' + order.id, mailErr);
|
||||
}
|
||||
}
|
||||
|
||||
createdOrders.push(order);
|
||||
};
|
||||
|
||||
await processOrderGroup(purchaseItems, 'purchase', 'invoice');
|
||||
await processOrderGroup(subscriptionItems, 'subscription', 'sepa');
|
||||
|
||||
return NextResponse.json({ success: true, orders: createdOrders });
|
||||
} catch (error: any) {
|
||||
console.error('Checkout error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function Datenschutz() {
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Datenschutzerklärung
|
||||
</h1>
|
||||
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>
|
||||
|
||||
@@ -106,6 +106,6 @@
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-sky-500 to-blue-600 dark:from-primary dark:to-blue-400;
|
||||
@apply text-blue-500 dark:text-blue-400;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function Impressum() {
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Impressum
|
||||
</h1>
|
||||
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>
|
||||
|
||||
@@ -12,6 +12,7 @@ const statusLabel: Record<string, string> = {
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
@@ -19,6 +20,7 @@ const statusClass: Record<string, string> = {
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
export default async function MyOrdersPage() {
|
||||
|
||||
@@ -49,6 +49,14 @@ export default async function OrderSuccessPage({
|
||||
|
||||
const o = order as Order
|
||||
const items = o.order_data?.items ?? []
|
||||
const groupedItems: Record<string, typeof items> = {}
|
||||
items.forEach(item => {
|
||||
const devName = item.device_name || 'Kasse 1'
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = []
|
||||
}
|
||||
groupedItems[devName].push(item)
|
||||
})
|
||||
|
||||
const taxRate = o.order_data?.tax_rate ?? 19
|
||||
const oneTimeItems = items.filter((item) => item.billing_interval === 'one_time')
|
||||
@@ -66,6 +74,7 @@ export default async function OrderSuccessPage({
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -104,23 +113,30 @@ export default async function OrderSuccessPage({
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Produkte */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
{/* Produkte gruppiert nach Kasse */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedItems).map(([deviceName, devItems]) => (
|
||||
<div key={deviceName} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
|
||||
<div className="bg-white/5 px-2 py-1 rounded text-xs text-primary font-bold">
|
||||
Kasse: {deviceName}
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
{devItems.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1 pl-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -70,37 +70,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
|
||||
{/* Background Decorative Animated Blobs */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 0.95, 1],
|
||||
rotate: [0, 90, 180, 270, 360],
|
||||
x: [0, 40, -30, 0],
|
||||
y: [0, -20, 50, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 0.9, 1.1, 1],
|
||||
rotate: [0, -90, -180, -270, -360],
|
||||
x: [0, -30, 40, 0],
|
||||
y: [0, 50, -30, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 25,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
|
||||
@@ -110,27 +80,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
variants={containerVariants}
|
||||
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
|
||||
>
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
|
||||
</motion.div>
|
||||
CASPOS Lizenz-Portal
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<motion.h1
|
||||
variants={itemVariants}
|
||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
|
||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight text-white"
|
||||
>
|
||||
CASPOS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-indigo-400 to-purple-500 font-black">Lizenzen & Module</span>
|
||||
CASPOS Software <span className="text-blue-500 font-black">Lizenzen & Module</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
@@ -146,7 +103,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
|
||||
>
|
||||
<Link href="/order" className="w-full sm:w-auto">
|
||||
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
|
||||
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold bg-blue-600 hover:bg-blue-500 text-white border border-blue-700/50 rounded-xl transition-all duration-200">
|
||||
Lizenz anfragen <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -163,19 +120,17 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
variants={itemVariants}
|
||||
className="w-full max-w-3xl pt-12"
|
||||
>
|
||||
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
|
||||
{/* Card border glow decoration */}
|
||||
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
Mein Dashboard Schnellzugriff
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link href="/my-customers" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
@@ -192,9 +147,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
|
||||
<Link href="/my-orders" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
@@ -223,10 +178,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
|
||||
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
|
||||
<Sparkles className="w-3 h-3" />CASPOS Sites
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-white">
|
||||
Workspace-Zentrale
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
|
||||
@@ -237,11 +189,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Card 1 */}
|
||||
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
@@ -255,11 +205,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
|
||||
{/* Card 2 */}
|
||||
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<BookOpen className="h-6 w-6" />
|
||||
@@ -273,11 +221,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
|
||||
{/* Card 3 */}
|
||||
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<LifeBuoy className="h-6 w-6" />
|
||||
@@ -296,17 +242,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
|
||||
<div className="container max-w-5xl mx-auto px-4">
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
|
||||
<Activity className="w-3 h-3" /> Ablauf
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center text-white">
|
||||
Wie funktioniert die Bereitstellung?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Connection line for Desktop */}
|
||||
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
|
||||
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] border-t border-dashed border-slate-800 z-0" />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 relative z-10">
|
||||
{/* Step 1 */}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<nav className="hidden md:flex gap-6 items-center">
|
||||
<Link
|
||||
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||
href="https://wiki.caspos.de"
|
||||
href="https://support.teamzone.softengine.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -111,8 +111,8 @@ export function SetupWizard() {
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-8 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-gradient bg-gradient-to-r from-blue-400 to-indigo-400">CASPOS</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">Initialisierung</span>
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3].map((s) => (
|
||||
|
||||
28
shop/components/admin/admin-nav-link.tsx
Normal file
28
shop/components/admin/admin-nav-link.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface AdminNavLinkProps {
|
||||
href: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminNavLink({ href, children }: AdminNavLinkProps) {
|
||||
const pathname = usePathname()
|
||||
const isActive = pathname === href
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-primary text-black font-semibold'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,12 @@ const categorySchema = z.object({
|
||||
icon: z.string().default('Package'),
|
||||
sort_order: z.coerce.number().int().min(0).default(0),
|
||||
is_required: z.boolean().default(true),
|
||||
allow_multiselect: z.boolean().default(false),
|
||||
free_items_limit: z.coerce.number().int().min(0).default(0),
|
||||
preselect: z.boolean().default(true),
|
||||
show_in_kauf: z.boolean().default(true),
|
||||
show_in_abo: z.boolean().default(true),
|
||||
resets_others: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type CategoryFormValues = z.infer<typeof categorySchema>
|
||||
@@ -65,15 +71,21 @@ export function CategoryDialog({
|
||||
icon: category?.icon || 'Package',
|
||||
sort_order: category?.sort_order ?? 0,
|
||||
is_required: category?.is_required ?? true,
|
||||
allow_multiselect: category?.allow_multiselect ?? false,
|
||||
free_items_limit: category?.free_items_limit ?? 0,
|
||||
preselect: category?.preselect ?? true,
|
||||
show_in_kauf: category?.show_in_kauf ?? true,
|
||||
show_in_abo: category?.show_in_abo ?? true,
|
||||
resets_others: category?.resets_others ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: CategoryFormValues) {
|
||||
try {
|
||||
if (category) {
|
||||
await updateCategory(category.id, values)
|
||||
await updateCategory(category.id, values as any)
|
||||
} else {
|
||||
await createCategory(values)
|
||||
await createCategory(values as any)
|
||||
}
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
@@ -184,7 +196,7 @@ export function CategoryDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_required"
|
||||
render={({ field }) => (
|
||||
@@ -207,6 +219,152 @@ export function CategoryDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_multiselect"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (checked) {
|
||||
form.setValue('preselect', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Mehrfachauswahl erlauben
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Erlaubt die Auswahl mehrerer Produkte/Module innerhalb dieser Kategorie.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!form.watch('allow_multiselect') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="preselect"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Vorauswahl aktivieren
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wenn aktiv, wird beim Öffnen des Wizards automatisch das erste Produkt dieser Kategorie ausgewählt.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resets_others"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Anderen Kategorien bei Auswahl zurücksetzen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wenn aktiv, setzt die Auswahl eines Artikels dieser Kategorie alle anderen Kategorien zurück.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
In Kauf anzeigen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Im Kauf-Zweig des Wizards anzeigen.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
In Abo anzeigen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Im Abo-Zweig des Wizards anzeigen.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch('allow_multiselect') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="free_items_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Inklusive Freie Artikel Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 2"
|
||||
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Bis zu dieser Anzahl sind gewählte Artikel in dieser Kategorie kostenlos. Alle darüber hinausgehenden werden voll berechnet (günstigste Artikel zuerst frei).
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="pt-4">
|
||||
|
||||
@@ -80,11 +80,18 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.is_required ? (
|
||||
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{category.is_required ? (
|
||||
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
||||
)}
|
||||
{category.allow_multiselect && (
|
||||
<Badge className="bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 whitespace-nowrap">
|
||||
Multi ({category.free_items_limit} frei)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -61,6 +61,7 @@ const productSchema = z.object({
|
||||
requirements: z.array(z.string()).default([]),
|
||||
exclusions: z.array(z.string()).default([]),
|
||||
modules: z.array(moduleSchema).default([]),
|
||||
allow_update_discount: z.boolean().default(true),
|
||||
})
|
||||
|
||||
type ProductFormValues = z.infer<typeof productSchema>
|
||||
@@ -77,8 +78,32 @@ export function CreateProductDialog({
|
||||
products?: Product[]
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const router = useRouter()
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
let isValid = false
|
||||
if (step === 1) {
|
||||
isValid = await form.trigger(['name', 'category_id', 'base_price'])
|
||||
} else if (step === 2) {
|
||||
isValid = await form.trigger('billing_interval')
|
||||
} else if (step === 3) {
|
||||
isValid = await form.trigger(['show_in_kauf', 'show_in_abo'])
|
||||
} else if (step === 4) {
|
||||
isValid = await form.trigger(['requirements', 'exclusions'])
|
||||
}
|
||||
if (isValid || step === 5) {
|
||||
setStep(prev => Math.min(prev + 1, 5))
|
||||
}
|
||||
}
|
||||
|
||||
const otherProducts = products.filter(p => p.id !== product?.id)
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
@@ -93,6 +118,7 @@ export function CreateProductDialog({
|
||||
show_in_abo: product ? (product.show_in_abo ?? true) : true,
|
||||
requirements: product?.requirements || [],
|
||||
exclusions: product?.exclusions || [],
|
||||
allow_update_discount: product ? (product.allow_update_discount ?? true) : true,
|
||||
modules: product?.modules?.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -134,7 +160,7 @@ export function CreateProductDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button className="bg-primary hover:bg-primary/90">
|
||||
@@ -142,7 +168,7 @@ export function CreateProductDialog({
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
|
||||
@@ -155,142 +181,209 @@ export function CreateProductDialog({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-hidden flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto pr-4 space-y-6 py-4 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Step Indicator */}
|
||||
{!product && (
|
||||
<div className="flex justify-between items-center mb-6 border-b border-slate-100 dark:border-white/5 pb-4">
|
||||
{[
|
||||
{ nr: 1, label: 'Allgemein' },
|
||||
{ nr: 2, label: 'Abrechnung' },
|
||||
{ nr: 3, label: 'Sichtbarkeit' },
|
||||
{ nr: 4, label: 'Abhängigkeiten' },
|
||||
{ nr: 5, label: 'Module' }
|
||||
].map(s => (
|
||||
<div key={s.nr} className="flex flex-col items-center gap-1 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
|
||||
step === s.nr
|
||||
? 'bg-primary text-white'
|
||||
: step > s.nr
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
{s.nr}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(product || step === 1) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 2) && (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 3) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch('billing_interval') === 'one_time' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_update_discount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Update-Rabatt erlauben</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">
|
||||
Kunden erhalten für dieses Produkt den selektiven Update-Rabatt.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 4) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Product Requirements */}
|
||||
<FormField
|
||||
@@ -384,33 +477,17 @@ export function CreateProductDialog({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="bg-slate-200 dark:bg-white/10" />
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 5) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<PlusCircle className="w-5 h-5 text-primary" />
|
||||
Module
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => {
|
||||
@@ -423,79 +500,90 @@ export function CreateProductDialog({
|
||||
.filter((_, idx) => idx !== index)
|
||||
|
||||
return (
|
||||
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Cloud Storage" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Aufpreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div key={field.id} className="p-4 border border-slate-200 dark:border-white/10 rounded-xl bg-slate-50 dark:bg-white/5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Zusatzlizenz" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Preis (€/Monat)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20 mt-6"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
|
||||
{/* Requirements Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: reqField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{otherModules.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: mReqField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Erfordert Module</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (reqField.value || []).includes(other.id)
|
||||
const checked = (mReqField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`req-${index}-${other.id}`}
|
||||
id={`mod-req-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = reqField.value || []
|
||||
const currentVal = mReqField.value || []
|
||||
if (checkedState) {
|
||||
reqField.onChange([...currentVal, other.id])
|
||||
mReqField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
reqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mReqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`req-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-req-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -503,44 +591,38 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Exclusions Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: exclField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: mExclField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Schließt Module aus</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (exclField.value || []).includes(other.id)
|
||||
const checked = (mExclField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`excl-${index}-${other.id}`}
|
||||
id={`mod-excl-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = exclField.value || []
|
||||
const currentVal = mExclField.value || []
|
||||
if (checkedState) {
|
||||
exclField.onChange([...currentVal, other.id])
|
||||
mExclField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
exclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mExclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`excl-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-excl-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -548,12 +630,12 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
@@ -591,18 +673,73 @@ export function CreateProductDialog({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-center py-6 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-lg text-slate-500 dark:text-slate-400 text-sm">
|
||||
Noch keine Module hinzugefügt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10 flex justify-between items-center sm:justify-between">
|
||||
{product ? (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep(prev => prev - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step < 5 ? (
|
||||
<Button type="button" onClick={nextStep} className="bg-primary hover:bg-primary/90 text-black font-medium">
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -24,6 +24,7 @@ const statusLabel: Record<string, string> = {
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
@@ -31,6 +32,7 @@ const statusClass: Record<string, string> = {
|
||||
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
|
||||
};
|
||||
|
||||
export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
@@ -43,7 +45,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
setOrders(initialOrders);
|
||||
}, [initialOrders]);
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled') => {
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
|
||||
setUpdatingId(orderId);
|
||||
try {
|
||||
await updateOrderStatus(orderId, newStatus);
|
||||
@@ -126,6 +128,14 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
>
|
||||
Storniert
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "rejected" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("rejected")}
|
||||
className="text-xs border-rose-500/20 hover:bg-rose-500/10 text-rose-400"
|
||||
>
|
||||
Abgelehnt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(true)
|
||||
const [showKauf, setShowKauf] = useState(true)
|
||||
const [showAbo, setShowAbo] = useState(true)
|
||||
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
|
||||
@@ -202,7 +202,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
||||
size="sm"
|
||||
onClick={toggleGroupByCategory}
|
||||
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
||||
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
||||
isGroupedByCategory ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 hover:text-white dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700' : ''
|
||||
}`}
|
||||
>
|
||||
<ArrowUpDown className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -23,12 +23,14 @@ const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
|
||||
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 50,
|
||||
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 2,
|
||||
},
|
||||
table: {
|
||||
|
||||
width: 'auto',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
@@ -63,21 +61,16 @@ const styles = StyleSheet.create({
|
||||
padding: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
tableCell: {
|
||||
margin: 'auto',
|
||||
marginTop: 5,
|
||||
fontSize: 10,
|
||||
},
|
||||
total: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 20,
|
||||
borderTop: 1,
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#000',
|
||||
paddingTop: 10,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 14,
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
footer: {
|
||||
@@ -88,34 +81,67 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: 8,
|
||||
borderTop: 1,
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#eee',
|
||||
paddingTop: 10,
|
||||
},
|
||||
sepaBox: {
|
||||
marginTop: 15,
|
||||
padding: 10,
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
}
|
||||
});
|
||||
|
||||
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
const items = orderSnapshot?.items ?? [];
|
||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
||||
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
||||
let oneTimeNet = 0;
|
||||
let monthlyNet = 0;
|
||||
|
||||
items.forEach((item: any) => {
|
||||
if (item.billing_interval === 'monthly') {
|
||||
monthlyNet += item.base_price || 0;
|
||||
} else {
|
||||
oneTimeNet += item.base_price || 0;
|
||||
}
|
||||
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
// Module sind monatliche wiederkehrende Beträge (Abo/Miete)
|
||||
monthlyNet += price;
|
||||
});
|
||||
});
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
const formattedPrice = (val: number) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
|
||||
const groupedItems: { [key: string]: any[] } = {};
|
||||
items.forEach((item: any) => {
|
||||
const devName = item.device_name || 'Kasse 1';
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = [];
|
||||
}
|
||||
groupedItems[devName].push(item);
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||
<Text>Software Solutions</Text>
|
||||
<Text>Die Kasse</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
<Text>CASPOS Computerabrechnungssysteme GmbH</Text>
|
||||
@@ -125,155 +151,127 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Anfragebestätigung</Text>
|
||||
<Text style={styles.title}>
|
||||
Anfragebestätigung: {order.order_number || order.id}
|
||||
</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Anfragender Kunde</Text>
|
||||
<Text style={styles.label}>Kunde</Text>
|
||||
<Text>{customer.company_name}</Text>
|
||||
<Text>{customer.first_name} {customer.last_name}</Text>
|
||||
<Text>{customer.address}</Text>
|
||||
<Text>{customer.zip_code} {customer.city}</Text>
|
||||
<Text>USt-IdNr: {customer.vat_id}</Text>
|
||||
{customer.bank_iban && (
|
||||
<Text style={{ marginTop: 4, color: '#666' }}>
|
||||
Bank: {customer.bank_name || '-'} · IBAN: {customer.bank_iban} · BIC: {customer.bank_bic || '-'} · Inhaber: {customer.bank_owner || '-'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Anfragedetails</Text>
|
||||
<Text>Anfrage-Nummer: {order.order_number || order.id}</Text>
|
||||
<Text style={styles.label}>Bestelldetails</Text>
|
||||
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
||||
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
|
||||
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||
</View>
|
||||
|
||||
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
|
||||
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
|
||||
<Text style={styles.label}>Update-Konditionen</Text>
|
||||
<Text style={{ fontWeight: 'bold' }}>
|
||||
{orderSnapshot.price_multiplier === 0
|
||||
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
|
||||
: orderSnapshot.price_multiplier === 0.15
|
||||
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.30
|
||||
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.45
|
||||
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.60
|
||||
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.90
|
||||
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
|
||||
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
|
||||
</Text>
|
||||
{orderSnapshot.last_license_date && (
|
||||
<Text style={{ marginTop: 2, color: '#666' }}>
|
||||
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||
</View>
|
||||
|
||||
{items.map((item: any, idx: number) => (
|
||||
<View key={idx}>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||
</View>
|
||||
<View style={styles.tableColPrice}>
|
||||
<Text>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ marginTop: 10, marginBottom: 15 }}>
|
||||
{Object.entries(groupedItems).map(([deviceName, devItems], groupIdx) => (
|
||||
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
|
||||
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
|
||||
Kasse: {deviceName}
|
||||
</Text>
|
||||
</View>
|
||||
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
const hasQty = qty > 1;
|
||||
return (
|
||||
<View key={mIdx} style={styles.tableRow}>
|
||||
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.tableColPrice}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
|
||||
</Text>
|
||||
</View>
|
||||
{devItems.map((item: any, idx: number) => (
|
||||
<View key={idx} style={{ marginBottom: 6 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#334155' }}>
|
||||
{item.product_name} ({item.category_name})
|
||||
</Text>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#0f172a' }}>
|
||||
{formattedPrice(item.base_price)} {item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
return (
|
||||
<View key={mIdx} style={{ flexDirection: 'row', justifyContent: 'space-between', paddingLeft: 12, marginBottom: 1 }}>
|
||||
<Text style={{ fontSize: 8, color: '#64748b' }}>
|
||||
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#64748b' }}>
|
||||
+{formattedPrice(price)} mtl.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.total}>
|
||||
<View style={{ width: '50%' }}>
|
||||
<View style={{ width: '75%' }}>
|
||||
{oneTimeNet > 0 && (
|
||||
<View style={{ marginBottom: monthlyNet > 0 ? 10 : 0 }}>
|
||||
{monthlyNet > 0 && (
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Einmaliger Betrag:</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<View style={{ marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Einmalige Beträge:</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</Text>
|
||||
<Text>{formattedPrice(oneTimeNet)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</Text>
|
||||
<Text>{formattedPrice(oneTimeTax)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||
<Text style={styles.totalLabel}>
|
||||
{monthlyNet > 0 ? 'Gesamtbetrag einmalig (brutto):' : 'Gesamtbetrag (brutto):'}
|
||||
</Text>
|
||||
<Text style={styles.totalLabel}>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt einmalig (brutto):</Text>
|
||||
<Text>{formattedPrice(oneTimeGross)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{oneTimeNet > 0 && monthlyNet > 0 && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: '#eee', marginVertical: 10 }} />
|
||||
)}
|
||||
|
||||
{monthlyNet > 0 && (
|
||||
<View>
|
||||
{oneTimeNet > 0 && (
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Monatlicher Betrag:</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<View style={{ marginTop: 4, paddingTop: 4, borderTopWidth: oneTimeNet > 0 ? 1 : 0, borderTopColor: '#e2e8f0' }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Monatlich wiederkehrende Beträge:</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</Text>
|
||||
<Text>{formattedPrice(monthlyNet)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</Text>
|
||||
<Text>{formattedPrice(monthlyTax)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||
<Text style={styles.totalLabel}>
|
||||
{oneTimeNet > 0 ? 'Gesamtbetrag monatlich (brutto):' : 'Gesamtbetrag (brutto):'}
|
||||
</Text>
|
||||
<Text style={styles.totalLabel}>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt monatlich (brutto):</Text>
|
||||
<Text>{formattedPrice(monthlyGross)} / mtl.</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isSubscription ? (
|
||||
<View style={styles.sepaBox}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 4 }}>SEPA-Lastschriftmandat</Text>
|
||||
<Text style={{ fontSize: 8, color: '#555' }}>
|
||||
Ich ermächtige die CASPOS GmbH, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von der CASPOS GmbH auf mein Konto gezogenen Lastschriften einzulösen. Das Mandat wird erst mit der finalen Aktivierung der Lizenzen durch den CASPOS-Support wirksam.
|
||||
</Text>
|
||||
{customer.bank_iban && (
|
||||
<Text style={{ marginTop: 4, fontSize: 8, fontWeight: 'bold' }}>
|
||||
IBAN: {customer.bank_iban.replace(/(.{4})/g, '$1 ')} · Inhaber: {customer.bank_owner || 'Kunde'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontSize: 9, color: '#555', fontStyle: 'italic' }}>
|
||||
Dies ist eine unverbindliche Bestätigung Ihrer Anfrage. Ein verbindliches Angebot wird Ihnen in Kürze von unserem Vertriebsteam zugesendet.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text>Vielen Dank für Ihre Bestellung!</Text>
|
||||
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
|
||||
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
|
||||
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
|
||||
@@ -102,22 +102,22 @@ export function LoginForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errorParam === "gesperrt" && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||
</p>
|
||||
)}
|
||||
{messageParam === "concurrent" && (
|
||||
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{errorParam === "gesperrt" && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||
</p>
|
||||
)}
|
||||
{messageParam === "concurrent" && (
|
||||
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
480
shop/components/wizard/license-lookup-panel.tsx
Normal file
480
shop/components/wizard/license-lookup-panel.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useTransition, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
KeyRound,
|
||||
Search,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Building2,
|
||||
User,
|
||||
CalendarDays,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
Hash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Fingerprint,
|
||||
} from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { lookupLicenseFromLicServer } from '@/lib/actions/licserver-config'
|
||||
|
||||
// ─── MOCK DATA (until real LicServer API is wired up) ───────────────────────
|
||||
const MOCK_LICENSES: Record<string, LicenseInfo> = {
|
||||
'CAS-2023-PRO-00123': {
|
||||
licenseKey: 'CAS-2023-PRO-00123',
|
||||
status: 'active',
|
||||
product: 'CAS genesisWorld Professional',
|
||||
edition: 'Professional',
|
||||
version: '14.1.2',
|
||||
seats: 10,
|
||||
customer: 'Mustermann GmbH',
|
||||
contact: 'Max Mustermann',
|
||||
issuedAt: '2023-04-01',
|
||||
expiresAt: '2026-03-31',
|
||||
maintenanceUntil: '2025-03-31',
|
||||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client'],
|
||||
},
|
||||
'CAS-2020-STD-00456': {
|
||||
licenseKey: 'CAS-2020-STD-00456',
|
||||
status: 'expired',
|
||||
product: 'CAS genesisWorld Standard',
|
||||
edition: 'Standard',
|
||||
version: '12.0.0',
|
||||
seats: 5,
|
||||
customer: 'Beispiel AG',
|
||||
contact: 'Erika Muster',
|
||||
issuedAt: '2020-01-15',
|
||||
expiresAt: '2023-01-14',
|
||||
maintenanceUntil: '2022-01-14',
|
||||
modules: ['CRM'],
|
||||
},
|
||||
'CAS-2024-ENT-00789': {
|
||||
licenseKey: 'CAS-2024-ENT-00789',
|
||||
status: 'active',
|
||||
product: 'CAS genesisWorld Enterprise',
|
||||
edition: 'Enterprise',
|
||||
version: '15.0.0',
|
||||
seats: 50,
|
||||
customer: 'Tech Solutions GmbH & Co. KG',
|
||||
contact: 'Julia Schneider',
|
||||
issuedAt: '2024-01-01',
|
||||
expiresAt: '2026-12-31',
|
||||
maintenanceUntil: '2026-12-31',
|
||||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client', 'AI Assistant', 'Analytics Pro'],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── TYPES ───────────────────────────────────────────────────────────────────
|
||||
export type LicenseStatus = 'active' | 'expired' | 'invalid' | 'suspended'
|
||||
|
||||
export interface LicenseInfo {
|
||||
licenseKey: string
|
||||
status: LicenseStatus
|
||||
product: string
|
||||
edition: string
|
||||
version: string
|
||||
seats: number
|
||||
customer: string
|
||||
contact: string
|
||||
issuedAt: string
|
||||
expiresAt: string
|
||||
maintenanceUntil: string
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
interface LicenseLookupPanelProps {
|
||||
/** Called when a valid license is found – so the wizard can prefill fields */
|
||||
onLicenseResolved?: (info: LicenseInfo) => void
|
||||
}
|
||||
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||||
function statusConfig(status: LicenseStatus) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return {
|
||||
label: 'Aktiv',
|
||||
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||||
ring: 'border-emerald-500/30',
|
||||
glow: 'shadow-emerald-500/10',
|
||||
}
|
||||
case 'expired':
|
||||
return {
|
||||
label: 'Abgelaufen',
|
||||
icon: <AlertTriangle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
|
||||
ring: 'border-amber-500/30',
|
||||
glow: 'shadow-amber-500/10',
|
||||
}
|
||||
case 'suspended':
|
||||
return {
|
||||
label: 'Gesperrt',
|
||||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||||
ring: 'border-red-500/30',
|
||||
glow: 'shadow-red-500/10',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Ungültig',
|
||||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||||
ring: 'border-red-500/30',
|
||||
glow: 'shadow-red-500/10',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(dateStr: string) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
|
||||
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
|
||||
try {
|
||||
return await lookupLicenseFromLicServer(key)
|
||||
} catch (err) {
|
||||
console.error('Lookup failed, falling back to mock details:', err)
|
||||
return MOCK_LICENSES[key.trim().toUpperCase()] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MAIN COMPONENT ───────────────────────────────────────────────────────────
|
||||
export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProps) {
|
||||
const [licenseKey, setLicenseKey] = useState('')
|
||||
const [result, setResult] = useState<LicenseInfo | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [modulesOpen, setModulesOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!licenseKey.trim()) return
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
startTransition(async () => {
|
||||
const info = await lookupLicense(licenseKey)
|
||||
if (info) {
|
||||
setResult(info)
|
||||
onLicenseResolved?.(info)
|
||||
} else {
|
||||
setNotFound(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleSearch()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setLicenseKey('')
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const sc = result ? statusConfig(result.status) : null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl">
|
||||
|
||||
{/* ── Panel Header (toggle) ─────────────────────────────────────────── */}
|
||||
<button
|
||||
id="license-lookup-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
|
||||
<KeyRound className="w-4 h-4 text-violet-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-semibold text-white">Alte Lizenznummer eingeben</p>
|
||||
<p className="text-[11px] text-slate-400">Upgrade / Migration</p>
|
||||
</div>
|
||||
</div>
|
||||
{expanded
|
||||
? <ChevronUp className="w-4 h-4 text-slate-400" />
|
||||
: <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="body"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-5 pb-5 space-y-4 border-t border-white/5">
|
||||
|
||||
{/* ── Input + Search Button ──────────────────────────────── */}
|
||||
<div className="pt-4">
|
||||
<p className="text-xs text-slate-400 mb-2 leading-relaxed">
|
||||
Geben Sie Ihre bisherige CAS-Lizenznummer ein,
|
||||
um Lizenzinformationen automatisch abzurufen.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Input */}
|
||||
<div className="relative flex-1">
|
||||
<Fingerprint className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-violet-400 pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="license-lookup-input"
|
||||
value={licenseKey}
|
||||
onChange={e => {
|
||||
setLicenseKey(e.target.value)
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="z. B. CAS-2023-PRO-00123"
|
||||
className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500
|
||||
focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10"
|
||||
/>
|
||||
{licenseKey && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Eingabe löschen"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<button
|
||||
id="license-lookup-search-btn"
|
||||
onClick={handleSearch}
|
||||
disabled={isPending || !licenseKey.trim()}
|
||||
className="flex items-center gap-1.5 px-3.5 h-10 rounded-xl bg-violet-600 hover:bg-violet-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium
|
||||
transition-all shadow-lg shadow-violet-500/20 shrink-0"
|
||||
>
|
||||
{isPending
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Search className="w-4 h-4" />}
|
||||
Prüfen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Demo quick-fill hints */}
|
||||
<p className="text-[10px] text-slate-500 mt-1.5">
|
||||
Demo:
|
||||
<span
|
||||
className="font-mono text-violet-400/80 cursor-pointer hover:text-violet-400 transition-colors"
|
||||
onClick={() => setLicenseKey('CAS-2023-PRO-00123')}
|
||||
>
|
||||
CAS-2023-PRO-00123
|
||||
</span>
|
||||
{' · '}
|
||||
<span
|
||||
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
|
||||
onClick={() => setLicenseKey('CAS-2020-STD-00456')}
|
||||
>
|
||||
CAS-2020-STD-00456
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Animated Result Area ──────────────────────────────── */}
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isPending && (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-7 rounded-lg bg-white/5 animate-pulse" />
|
||||
))}
|
||||
<p className="text-center text-[11px] text-slate-500 animate-pulse">
|
||||
Lizenzserver wird abgefragt…
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Not Found */}
|
||||
{notFound && !isPending && (
|
||||
<motion.div
|
||||
key="notfound"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-start gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">Lizenz nicht gefunden</p>
|
||||
<p className="text-xs text-red-400/70 mt-0.5">
|
||||
Die eingegebene Nummer wurde im Lizenzsystem nicht gefunden.
|
||||
Bitte prüfen Sie die Eingabe.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Result Card */}
|
||||
{result && !isPending && sc && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`rounded-xl border ${sc.ring} bg-slate-900/80 shadow-lg ${sc.glow} overflow-hidden`}
|
||||
>
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-slate-300" />
|
||||
<span className="text-xs font-mono text-slate-300 truncate max-w-[140px]">
|
||||
{result.licenseKey}
|
||||
</span>
|
||||
</div>
|
||||
<Badge className={`text-[10px] px-2 py-0.5 flex items-center gap-1 border ${sc.cls}`}>
|
||||
{sc.icon}
|
||||
{sc.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Info Rows */}
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
|
||||
{/* Product */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Package className="w-3.5 h-3.5 text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Produkt</p>
|
||||
<p className="text-xs font-semibold text-white leading-tight">{result.product}</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{result.edition} · v{result.version} · {result.seats} Arbeitsplätze
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-blue-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Lizenznehmer</p>
|
||||
<p className="text-xs font-semibold text-white leading-tight">{result.customer}</p>
|
||||
<p className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<User className="w-2.5 h-2.5" />
|
||||
{result.contact}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<CalendarDays className="w-2.5 h-2.5" /> Ausgestellt
|
||||
</p>
|
||||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.issuedAt)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<CalendarDays className="w-2.5 h-2.5" /> Läuft ab
|
||||
</p>
|
||||
<p className={`text-xs font-medium mt-0.5 ${result.status === 'expired' ? 'text-amber-400' : 'text-white'}`}>
|
||||
{fmt(result.expiresAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2 col-span-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<Hash className="w-2.5 h-2.5" /> Wartung bis
|
||||
</p>
|
||||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.maintenanceUntil)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules (collapsible) */}
|
||||
{result.modules.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setModulesOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between text-[10px] text-slate-400
|
||||
hover:text-white transition-colors pt-1"
|
||||
>
|
||||
<span className="uppercase tracking-wide">
|
||||
Lizenzierte Module ({result.modules.length})
|
||||
</span>
|
||||
{modulesOpen
|
||||
? <ChevronUp className="w-3 h-3" />
|
||||
: <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{modulesOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 pt-2">
|
||||
{result.modules.map(m => (
|
||||
<span
|
||||
key={m}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15
|
||||
border border-violet-500/30 text-violet-300"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom hint */}
|
||||
{result.status === 'active' && (
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-[10px] text-emerald-400/70 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Lizenz erkannt — Daten können für die Bestellung übernommen werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.status === 'expired' && (
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Lizenz abgelaufen — Upgrade empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
shop/components/wizard/progress-stepper.tsx
Normal file
39
shop/components/wizard/progress-stepper.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ProgressStepperProps {
|
||||
step: number
|
||||
basketItemsCount: number
|
||||
}
|
||||
|
||||
export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mb-12">
|
||||
<div className="flex justify-between relative">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||
step >= s
|
||||
? 'bg-blue-600 text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{step > s ? <Check className="w-5 h-5" /> : s}
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${step >= s ? 'text-blue-400' : 'text-slate-500'} flex items-center gap-1`}>
|
||||
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
|
||||
{s === 3 && basketItemsCount > 0 && (
|
||||
<span className="bg-blue-600 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
|
||||
{basketItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
shop/components/wizard/step-billing.tsx
Normal file
127
shop/components/wizard/step-billing.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CreditCard, Calendar, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface StepBillingProps {
|
||||
selectedBillingInterval: 'one_time' | 'monthly'
|
||||
handleBillingIntervalChange: (val: 'one_time' | 'monthly') => void
|
||||
customerMode: 'select' | 'create'
|
||||
selectedEndCustomerId: string | null
|
||||
lastLicenseDate: string
|
||||
setLastLicenseDate: (date: string) => void
|
||||
prevStep: () => void
|
||||
nextStep: () => void
|
||||
}
|
||||
|
||||
export function StepBilling({
|
||||
selectedBillingInterval,
|
||||
handleBillingIntervalChange,
|
||||
customerMode,
|
||||
selectedEndCustomerId,
|
||||
lastLicenseDate,
|
||||
setLastLicenseDate,
|
||||
prevStep,
|
||||
nextStep,
|
||||
}: StepBillingProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<CreditCard className="w-6 h-6 text-primary" />
|
||||
Abrechnungsmodell wählen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-6 py-4">
|
||||
{/* Option 1: Kauf */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('one_time')}
|
||||
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||
selectedBillingInterval === 'one_time'
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'one_time' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||
<CreditCard className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Einmaliger Kauf</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren.
|
||||
</p>
|
||||
</div>
|
||||
{selectedBillingInterval === 'one_time' && (
|
||||
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Option 2: Abo */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('monthly')}
|
||||
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||
selectedBillingInterval === 'monthly'
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'monthly' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Monatliches Abo (Miete)</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit.
|
||||
</p>
|
||||
</div>
|
||||
{selectedBillingInterval === 'monthly' && (
|
||||
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-6 max-w-md animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
Datum der letzten CAS-Lizenz (falls vorhanden)
|
||||
</Label>
|
||||
<Input
|
||||
id="last-license-date"
|
||||
type="date"
|
||||
value={lastLicenseDate}
|
||||
onChange={e => setLastLicenseDate(e.target.value)}
|
||||
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||
<Button variant="ghost" className="text-white" onClick={prevStep}>
|
||||
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
||||
</Button>
|
||||
<Button onClick={nextStep}>
|
||||
Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
224
shop/components/wizard/step-customer.tsx
Normal file
224
shop/components/wizard/step-customer.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { User, Building2, UserPlus, Search, Check, Loader2, ChevronRight } from 'lucide-react'
|
||||
import { EndCustomer, Profile } from '@/lib/types'
|
||||
|
||||
interface StepCustomerProps {
|
||||
isAdmin: boolean
|
||||
companies: any[]
|
||||
selectedCompanyId: string | null
|
||||
setSelectedCompanyId: (id: string | null) => void
|
||||
customerMode: 'select' | 'create'
|
||||
setCustomerMode: (mode: 'select' | 'create') => void
|
||||
endCustomers: EndCustomer[]
|
||||
searchTerm: string
|
||||
setSearchTerm: (term: string) => void
|
||||
filteredEndCustomers: EndCustomer[]
|
||||
selectedEndCustomerId: string | null
|
||||
setSelectedEndCustomerId: (id: string | null) => void
|
||||
newCustomerForm: any
|
||||
setNewCustomerForm: React.Dispatch<React.SetStateAction<any>>
|
||||
handleCreateCustomer: () => Promise<void>
|
||||
isCreatingCustomer: boolean
|
||||
nextStep: () => void
|
||||
}
|
||||
|
||||
export function StepCustomer({
|
||||
isAdmin,
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
setSelectedCompanyId,
|
||||
customerMode,
|
||||
setCustomerMode,
|
||||
endCustomers,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filteredEndCustomers,
|
||||
selectedEndCustomerId,
|
||||
setSelectedEndCustomerId,
|
||||
newCustomerForm,
|
||||
setNewCustomerForm,
|
||||
handleCreateCustomer,
|
||||
isCreatingCustomer,
|
||||
nextStep,
|
||||
}: StepCustomerProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<User className="w-6 h-6 text-primary" />
|
||||
Für wen bestellen Sie?
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Wählen Sie einen Endkunden aus oder legen Sie einen neuen an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Admin Company Selector */}
|
||||
{isAdmin && (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
|
||||
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
Zugeordnetes Unternehmen (Admin-Option)
|
||||
</Label>
|
||||
<select
|
||||
id="order-company"
|
||||
value={selectedCompanyId || ''}
|
||||
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
|
||||
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
|
||||
>
|
||||
<option value="">Keine Firma zugewiesen</option>
|
||||
{companies.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modus-Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('select')}
|
||||
className={`gap-2 ${customerMode === 'select' ? '' : 'text-white'}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4" /> Bestandskunde wählen
|
||||
</Button>
|
||||
<Button
|
||||
variant={customerMode === 'create' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('create')}
|
||||
className={`gap-2 ${customerMode === 'create' ? '' : 'text-white'}`}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modus A: Bestandskunde wählen */}
|
||||
{customerMode === 'select' && (
|
||||
<div className="space-y-4">
|
||||
{endCustomers.filter(c => !c.is_anonymized).length === 0 ? (
|
||||
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
|
||||
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
|
||||
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10 text-white">
|
||||
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Kunden suchen nach Name, Ort, PLZ..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
{filteredEndCustomers.length === 0 ? (
|
||||
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
|
||||
<p className="text-slate-400">Keine passenden Kunden gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||
{filteredEndCustomers.map(customer => (
|
||||
<label
|
||||
key={customer.id}
|
||||
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${selectedEndCustomerId === customer.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="end_customer"
|
||||
value={customer.id}
|
||||
checked={selectedEndCustomerId === customer.id}
|
||||
onChange={() => setSelectedEndCustomerId(customer.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-white">{customer.company_name}</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
|
||||
{customer.first_name || customer.last_name ? ' · ' : ''}
|
||||
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
{selectedEndCustomerId === customer.id && (
|
||||
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modus B: Neuen Kunden anlegen */}
|
||||
{customerMode === 'create' && (
|
||||
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
{([
|
||||
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
|
||||
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
|
||||
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
|
||||
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
|
||||
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
|
||||
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
|
||||
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
|
||||
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
|
||||
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
|
||||
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
|
||||
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
|
||||
] as const).map(({ label, key, span, placeholder }) => (
|
||||
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
|
||||
<Label className="text-slate-300 text-sm">{label}</Label>
|
||||
<Input
|
||||
value={newCustomerForm[key] || ''}
|
||||
onChange={e => setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateCustomer}
|
||||
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
|
||||
Kunden speichern & auswählen
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-white" onClick={() => setCustomerMode('select')}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={
|
||||
customerMode === 'create' ||
|
||||
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
|
||||
}
|
||||
>
|
||||
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
295
shop/components/wizard/step-software.tsx
Normal file
295
shop/components/wizard/step-software.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShoppingCart, Check, AlertCircle } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||
|
||||
interface StepSoftwareProps {
|
||||
visibleCategories: Category[]
|
||||
products: Product[]
|
||||
selections: Record<string, CategorySelection>
|
||||
selectProduct: (catId: string, prodId: string) => void
|
||||
isProductDisabled: (prod: Product, catId: string) => boolean
|
||||
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
|
||||
toggleModule: (catId: string, modId: string) => void
|
||||
moduleQuantities: Record<string, number>
|
||||
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
|
||||
selectedBillingInterval: 'one_time' | 'monthly'
|
||||
billingLabel: (interval: string) => string
|
||||
billingBadgeClass: (interval: string) => string
|
||||
}
|
||||
|
||||
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||
const LucideIcon = icon ? (Icons as any)[icon] : null
|
||||
if (!LucideIcon) return <Icons.HelpCircle className={className} />
|
||||
return <LucideIcon className={className} />
|
||||
}
|
||||
|
||||
export function StepSoftware({
|
||||
visibleCategories,
|
||||
products,
|
||||
selections,
|
||||
selectProduct,
|
||||
isProductDisabled,
|
||||
isModuleDisabled,
|
||||
toggleModule,
|
||||
moduleQuantities,
|
||||
setModuleQuantities,
|
||||
selectedBillingInterval,
|
||||
billingLabel,
|
||||
billingBadgeClass,
|
||||
}: StepSoftwareProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<ShoppingCart className="w-6 h-6 text-blue-400" />
|
||||
Software wählen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Wählen Sie pro Kategorie mindestens einen Artikel aus.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{visibleCategories.map((cat, idx) => {
|
||||
const catProducts = products.filter(p => {
|
||||
if (p.category_id !== cat.id) return false
|
||||
return selectedBillingInterval === 'one_time'
|
||||
? p.show_in_kauf !== false
|
||||
: p.show_in_abo !== false
|
||||
})
|
||||
const sel = selections[cat.id]
|
||||
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
|
||||
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
|
||||
{cat.description && (
|
||||
<p className="text-slate-400 text-xs">{cat.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{sel?.productIds && sel.productIds.length > 0 ? (
|
||||
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
|
||||
</Badge>
|
||||
) : sel?.productId ? (
|
||||
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<Check className="w-3 h-3 mr-1" /> Ausgewählt
|
||||
</Badge>
|
||||
) : cat.is_required ? (
|
||||
<Badge variant="destructive" className="ml-auto opacity-80">
|
||||
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
|
||||
Optional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catProducts.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm italic">
|
||||
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||
</p>
|
||||
) : cat.allow_multiselect ? (
|
||||
<div className="grid gap-3">
|
||||
{catProducts.map(product => {
|
||||
const isChecked = sel?.productIds?.includes(product.id) ?? false
|
||||
const disabled = isProductDisabled(product, cat.id)
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<Label
|
||||
onClick={() => !disabled && selectProduct(cat.id, product.id)}
|
||||
className={`flex flex-col items-start p-4 rounded-xl border-2 transition-all ${disabled
|
||||
? 'border-white/5 bg-white/5 opacity-50 cursor-not-allowed'
|
||||
: isChecked
|
||||
? 'border-primary bg-primary/5 cursor-pointer'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => { }}
|
||||
className="border-white/20 data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<span className="font-bold text-base text-white">{product.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary font-semibold text-sm">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
|
||||
)}
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-xs text-slate-500 mt-1 pl-7">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={sel?.productId ?? ''}
|
||||
onValueChange={id => selectProduct(cat.id, id)}
|
||||
className="grid gap-3"
|
||||
>
|
||||
{catProducts.map(product => {
|
||||
const disabled = isProductDisabled(product, cat.id)
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<RadioGroupItem
|
||||
value={product.id}
|
||||
id={`${cat.id}-${product.id}`}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={disabled ? undefined : `${cat.id}-${product.id}`}
|
||||
className={`flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 transition-all ${disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-base text-white">{product.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary font-semibold text-sm">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
|
||||
)}
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-xs text-slate-500 mt-1">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{/* Modules */}
|
||||
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
|
||||
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
|
||||
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
|
||||
{selectedProduct.modules.map(module => {
|
||||
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
|
||||
const checked = sel?.moduleIds.includes(module.id) ?? false
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`mod-${cat.id}-${module.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`mod-${cat.id}-${module.id}`}
|
||||
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span>{module.name}</span>
|
||||
<span className="text-primary font-bold">
|
||||
+{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}
|
||||
</span>
|
||||
</Label>
|
||||
{module.description && (
|
||||
<p className="text-xs text-slate-400">{module.description}</p>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{module.requirements?.length && !module.requirements.some(
|
||||
reqId => sel?.moduleIds.includes(reqId)
|
||||
)
|
||||
? 'Benötigt weitere Module'
|
||||
: 'Nicht kombinierbar mit aktueller Auswahl'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scalable Quantity */}
|
||||
{checked && module.has_quantity && (
|
||||
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
|
||||
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
|
||||
<Input
|
||||
id={`qty-${module.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={moduleQuantities[module.id] || 1}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, parseInt(e.target.value) || 1)
|
||||
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
|
||||
}}
|
||||
className="w-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
Gesamt: {new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price * (moduleQuantities[module.id] || 1))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
275
shop/components/wizard/step-summary.tsx
Normal file
275
shop/components/wizard/step-summary.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ShieldCheck, Building2, Calendar, Loader2 } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { Category, Product, EndCustomer, Profile } from '@/lib/types'
|
||||
|
||||
interface StepSummaryProps {
|
||||
finalItemsToShow: any[]
|
||||
visibleCategories: Category[]
|
||||
products: Product[]
|
||||
oneTimeTotal: number
|
||||
monthlyTotal: number
|
||||
oneTimeNet: number
|
||||
oneTimeTax: number
|
||||
oneTimeGross: number
|
||||
monthlyNet: number
|
||||
monthlyTax: number
|
||||
monthlyGross: number
|
||||
updatePriceModifier: { label?: string }
|
||||
selectedEndCustomer: EndCustomer | null
|
||||
customerData: Partial<Profile>
|
||||
lastLicenseDate: string
|
||||
handleSubmit: () => Promise<void>
|
||||
isSubmitting: boolean
|
||||
initialOrder: any
|
||||
prevStep: () => void
|
||||
}
|
||||
|
||||
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||
const LucideIcon = icon ? (Icons as any)[icon] : null
|
||||
if (!LucideIcon) return <Icons.HelpCircle className={className} />
|
||||
return <LucideIcon className={className} />
|
||||
}
|
||||
|
||||
export function StepSummary({
|
||||
finalItemsToShow,
|
||||
visibleCategories,
|
||||
products,
|
||||
oneTimeTotal,
|
||||
monthlyTotal,
|
||||
oneTimeNet,
|
||||
oneTimeTax,
|
||||
oneTimeGross,
|
||||
monthlyNet,
|
||||
monthlyTax,
|
||||
monthlyGross,
|
||||
updatePriceModifier,
|
||||
selectedEndCustomer,
|
||||
customerData,
|
||||
lastLicenseDate,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
initialOrder,
|
||||
prevStep,
|
||||
}: StepSummaryProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
|
||||
<CardHeader>
|
||||
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
|
||||
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl text-white">Anfrage prüfen</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 text-left">
|
||||
<div className="p-4 rounded-lg bg-white/5 space-y-6">
|
||||
{finalItemsToShow.map((item, itemIdx) => (
|
||||
<div key={itemIdx} className="space-y-3 border-b border-white/10 pb-4 last:border-0 last:pb-0">
|
||||
<div className="flex justify-between items-center bg-white/5 p-2 rounded">
|
||||
<span className="text-white font-bold text-sm">Kasse: {item.deviceName}</span>
|
||||
<span className="text-xs text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
|
||||
</div>
|
||||
|
||||
{visibleCategories.map(cat => {
|
||||
const sel = item.selections[cat.id]
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel?.productIds) {
|
||||
sel.productIds.forEach((pId: string) => {
|
||||
const p = products.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel?.productId) {
|
||||
const p = products.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
if (selectedProds.length === 0) return null
|
||||
|
||||
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
|
||||
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="space-y-1 pl-2">
|
||||
<div className="text-slate-400 font-semibold text-xs flex items-center gap-1">
|
||||
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary" />
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const actualPrice = isFree ? 0 : prod.base_price
|
||||
const catTotal =
|
||||
Number(actualPrice) +
|
||||
(sel?.moduleIds?.reduce((acc: number, mId: string) => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
const qty = item.moduleQuantities[mId] || 1
|
||||
return acc + (Number(mod?.price ?? 0) * qty)
|
||||
}, 0) || 0)
|
||||
|
||||
return (
|
||||
<div key={prod.id} className="pl-3">
|
||||
<div className="flex justify-between font-semibold text-sm text-white">
|
||||
<span>
|
||||
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
|
||||
</span>
|
||||
<span>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
|
||||
</span>
|
||||
</div>
|
||||
{sel?.moduleIds && sel.moduleIds.length > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Module: {sel.moduleIds
|
||||
.map((id: string) => {
|
||||
const mod = prod.modules?.find(m => m.id === id)
|
||||
const qty = item.moduleQuantities[id] || 1
|
||||
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<Separator className="bg-white/10" />
|
||||
<div className="text-sm text-slate-300 space-y-1">
|
||||
{selectedEndCustomer ? (
|
||||
<>
|
||||
<p className="font-semibold text-white flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-primary" />
|
||||
{selectedEndCustomer.company_name}
|
||||
</p>
|
||||
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
|
||||
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
|
||||
{lastLicenseDate && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-white">{customerData.company_name}</p>
|
||||
<p>{customerData.first_name} {customerData.last_name}</p>
|
||||
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-4 border-t border-white/10 pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag:</p>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 pt-2 border-t border-white/10">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag:</p>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : oneTimeTotal > 0 ? (
|
||||
<div className="space-y-1 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||
Datenschutzerklärung
|
||||
</a>.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button
|
||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
initialOrder ? (
|
||||
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird aktualisiert...</>
|
||||
) : (
|
||||
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird versendet...</>
|
||||
)
|
||||
) : (
|
||||
initialOrder ? 'Anfrage aktualisieren' : 'Anfrage versenden'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||
Noch etwas ändern
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
297
shop/components/wizard/summary-sidebar.tsx
Normal file
297
shop/components/wizard/summary-sidebar.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2, UserPlus, ChevronRight, AlertCircle, Check } from 'lucide-react'
|
||||
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||
|
||||
interface SummarySidebarProps {
|
||||
visibleCategories: Category[]
|
||||
selections: Record<string, CategorySelection>
|
||||
products: Product[]
|
||||
moduleQuantities: Record<string, number>
|
||||
billingLabel: (interval: string) => string
|
||||
oneTimeTotal: number
|
||||
monthlyTotal: number
|
||||
oneTimeNet: number
|
||||
oneTimeTax: number
|
||||
oneTimeGross: number
|
||||
monthlyNet: number
|
||||
monthlyTax: number
|
||||
monthlyGross: number
|
||||
updatePriceModifier: { label?: string }
|
||||
allCategoriesFilled: boolean
|
||||
productValidationErrors: string[]
|
||||
basketItems: any[]
|
||||
editingIdx: number | null
|
||||
editBasketItem: (idx: number) => void
|
||||
deleteBasketItem: (idx: number) => void
|
||||
deviceName: string
|
||||
setDeviceName: (name: string) => void
|
||||
addToBasket: () => void
|
||||
isNextStepDisabled: boolean
|
||||
hasActiveSelection: boolean
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
}
|
||||
|
||||
export function SummarySidebar({
|
||||
visibleCategories,
|
||||
selections,
|
||||
products,
|
||||
moduleQuantities,
|
||||
billingLabel,
|
||||
oneTimeTotal,
|
||||
monthlyTotal,
|
||||
oneTimeNet,
|
||||
oneTimeTax,
|
||||
oneTimeGross,
|
||||
monthlyNet,
|
||||
monthlyTax,
|
||||
monthlyGross,
|
||||
updatePriceModifier,
|
||||
allCategoriesFilled,
|
||||
productValidationErrors,
|
||||
basketItems,
|
||||
editingIdx,
|
||||
editBasketItem,
|
||||
deleteBasketItem,
|
||||
deviceName,
|
||||
setDeviceName,
|
||||
addToBasket,
|
||||
isNextStepDisabled,
|
||||
hasActiveSelection,
|
||||
nextStep,
|
||||
prevStep,
|
||||
}: SummarySidebarProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-primary/20 backdrop-blur-lg bg-slate-950/75 flex flex-col max-h-[80vh]">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1 overflow-y-auto subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||
{visibleCategories.map(cat => {
|
||||
const sel = selections[cat.id]
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel?.productIds) {
|
||||
sel.productIds.forEach(pId => {
|
||||
const p = products.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel?.productId) {
|
||||
const p = products.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
if (selectedProds.length === 0) return (
|
||||
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
|
||||
{cat.is_required ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
) : (
|
||||
<span className="w-3 h-3 inline-block" />
|
||||
)}
|
||||
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
|
||||
</div>
|
||||
)
|
||||
|
||||
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
|
||||
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400 font-medium">{cat.name}</span>
|
||||
</div>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const actualPrice = isFree ? 0 : prod.base_price
|
||||
return (
|
||||
<div key={prod.id} className="space-y-0.5 pl-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
|
||||
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{sel.moduleIds.map(mId => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
const qty = moduleQuantities[mId] || 1
|
||||
return mod ? (
|
||||
<div key={mId} className="flex justify-between text-xs pl-2">
|
||||
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
|
||||
<span className="text-slate-300">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Separator className="bg-white/10" />
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmalig:</p>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-white">
|
||||
<span>Gesamt (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 pt-2 border-t border-white/10">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlich:</p>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-white">
|
||||
<span>Gesamt (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : oneTimeTotal > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
|
||||
<p className="font-semibold flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Update-Rabatt aktiv
|
||||
</p>
|
||||
<p>{updatePriceModifier.label}</p>
|
||||
</div>
|
||||
)}
|
||||
{!allCategoriesFilled && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
|
||||
</p>
|
||||
)}
|
||||
{productValidationErrors.map((err, errIdx) => (
|
||||
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{basketItems.length > 0 && (
|
||||
<div className="pt-4 border-t border-white/10 space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Warenkorb ({basketItems.length}):</p>
|
||||
<div className="space-y-1.5">
|
||||
{basketItems.map((item, idx) => (
|
||||
<div key={idx} className={`flex justify-between items-center text-xs bg-white/5 p-2 rounded border transition-all duration-300 ${idx === editingIdx ? 'border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]' : 'border-white/10'}`}>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-medium">{item.deviceName}</span>
|
||||
<span className="text-[10px] text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-7 h-7 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
onClick={() => editBasketItem(idx)}
|
||||
title="Kasse bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-7 h-7 hover:bg-white/10 text-destructive hover:text-red-400"
|
||||
onClick={() => deleteBasketItem(idx)}
|
||||
title="Kasse löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 shrink-0">
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
|
||||
<Input
|
||||
id="device-name"
|
||||
placeholder="z.B. Hauptkasse, Theke"
|
||||
value={deviceName}
|
||||
onChange={e => setDeviceName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white text-xs h-9 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-primary/30 text-white hover:bg-primary/10 gap-2 h-10 text-sm"
|
||||
onClick={addToBasket}
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {editingIdx !== null ? '💾 Änderungen an Kasse speichern' : (hasActiveSelection ? 'Kasse speichern' : 'Kasse anlegen')}
|
||||
</Button>
|
||||
<Separator className="bg-white/10 my-1" />
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={nextStep}
|
||||
disabled={basketItems.length === 0 && isNextStepDisabled}
|
||||
>
|
||||
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||
Zurück zum Abrechnungsmodell
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
40
shop/components/wizard/toast-notification.tsx
Normal file
40
shop/components/wizard/toast-notification.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ToastProps {
|
||||
toast: { message: string; type: 'error' | 'success' } | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ToastNotification({ toast, onClose }: ToastProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className={`fixed bottom-5 right-5 z-50 flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md transition-all duration-300 max-w-md ${
|
||||
toast.type === 'error'
|
||||
? 'bg-red-500/20 border-red-500/40 text-red-200'
|
||||
: 'bg-green-500/20 border-green-500/40 text-green-200'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className={`w-5 h-5 shrink-0 ${toast.type === 'error' ? 'text-red-400' : 'text-green-400'}`} />
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-5 h-5 ml-auto text-slate-400 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,8 @@ export async function signIn(email: string, password: string) {
|
||||
|
||||
const userId = data.user?.id
|
||||
if (userId) {
|
||||
let { data: userData, error: userError } = await supabase
|
||||
const adminClient = createAdminClient()
|
||||
let { data: userData, error: userError } = await adminClient
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath, unstable_noStore as noStore } from 'next/cache'
|
||||
import { getLicServerConfig, getLicServerConfigSystem } from './licserver-config'
|
||||
|
||||
export async function getCompanies() {
|
||||
noStore()
|
||||
@@ -219,3 +220,135 @@ export async function adminDeleteEndCustomer(id: string) {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function syncCompaniesFromLicServer() {
|
||||
const admin = createAdminClient()
|
||||
const cfg = await getLicServerConfigSystem()
|
||||
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
throw new Error('LicServer ist nicht konfiguriert.')
|
||||
}
|
||||
|
||||
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1000`, {
|
||||
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const result = await res.json()
|
||||
const partners = result.items || []
|
||||
|
||||
if (partners.length === 0) {
|
||||
return { success: true, count: 0 }
|
||||
}
|
||||
|
||||
const upsertRows = partners.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name || 'Unbenannt',
|
||||
email: p.email || null,
|
||||
}))
|
||||
|
||||
const { error } = await admin
|
||||
.from('companies')
|
||||
.upsert(upsertRows, { onConflict: 'id' })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
revalidatePath('/admin/companies')
|
||||
revalidatePath('/admin/users')
|
||||
|
||||
return { success: true, count: partners.length }
|
||||
}
|
||||
|
||||
export async function syncCustomersFromLicServer() {
|
||||
const admin = createAdminClient()
|
||||
const cfg = await getLicServerConfigSystem()
|
||||
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
throw new Error('LicServer ist nicht konfiguriert.')
|
||||
}
|
||||
|
||||
// 1. Get all local companies
|
||||
const { data: companies, error: compErr } = await admin
|
||||
.from('companies')
|
||||
.select('id')
|
||||
|
||||
if (compErr) throw compErr
|
||||
if (!companies || companies.length === 0) {
|
||||
return { success: true, count: 0 }
|
||||
}
|
||||
|
||||
let totalSynced = 0
|
||||
const allUpsertRows: any[] = []
|
||||
|
||||
// 2. Fetch customers for each company
|
||||
for (const company of companies) {
|
||||
try {
|
||||
// Workaround for LicServer crash on null/empty search parameter:
|
||||
// We query using common vowels/umlauts in parallel and de-duplicate locally.
|
||||
const searchChars = ['a', 'e', 'i', 'o', 'u', 'ä', 'ö', 'ü', 'y']
|
||||
const customerMap = new Map<string, any>()
|
||||
|
||||
await Promise.all(
|
||||
searchChars.map(async char => {
|
||||
try {
|
||||
const res = await fetch(`${cfg.base_url}/api-v1/partners/${company.id}/customers?pageSize=1000&search=${encodeURIComponent(char)}`, {
|
||||
headers: { 'X-Api-Key': cfg.api_key!, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
const items = result.items || []
|
||||
for (const c of items) {
|
||||
customerMap.set(c.id, c)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`[sync-customers] Error fetching with search=${char} for partner ${company.id}:`, e.message || e)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const customers = Array.from(customerMap.values())
|
||||
|
||||
for (const c of customers) {
|
||||
const streetCombined = [c.street, c.houseNumber, c.houseNumberAddon]
|
||||
.filter(Boolean)
|
||||
.map(s => s.trim())
|
||||
.join(' ')
|
||||
|
||||
allUpsertRows.push({
|
||||
id: c.id,
|
||||
partner_id: company.id,
|
||||
company_name: c.name || 'Unbenannt',
|
||||
street: streetCombined || null,
|
||||
zip: c.zipCode || null,
|
||||
city: c.city || null,
|
||||
email: c.email || null,
|
||||
is_anonymized: false,
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`[sync-customers] Error fetching customers for partner ${company.id}:`, e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
if (allUpsertRows.length > 0) {
|
||||
const { error: upsertErr } = await admin
|
||||
.from('end_customers')
|
||||
.upsert(allUpsertRows, { onConflict: 'id' })
|
||||
|
||||
if (upsertErr) throw upsertErr
|
||||
totalSynced = allUpsertRows.length
|
||||
}
|
||||
|
||||
revalidatePath('/my-customers')
|
||||
revalidatePath('/order')
|
||||
revalidatePath('/admin/companies')
|
||||
|
||||
return { success: true, count: totalSynced }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ interface EmailDetails {
|
||||
customerCompanyName: string
|
||||
totalDetailsText: string
|
||||
totalDetailsHtml: string
|
||||
itemsDetailsText?: string
|
||||
itemsDetailsHtml?: string
|
||||
}
|
||||
|
||||
export function getOrderEmailTemplate(
|
||||
@@ -11,12 +13,12 @@ export function getOrderEmailTemplate(
|
||||
siteUrl: string,
|
||||
isUpdate: boolean = false
|
||||
) {
|
||||
const title = isUpdate ? 'Ihre Anfrage wurde aktualisiert!' : 'Vielen Dank für Ihre Anfrage!'
|
||||
const intro = isUpdate
|
||||
? `Ihr Anfrage mit der Nummer ${details.orderNumber} wurde erfolgreich aktualisiert.`
|
||||
: 'wir freuen uns sehr über Ihre Anfrage bei CASPOS Shop. Ihre Anfrage wurde erfolgreich entgegengenommen und wird nun verarbeitet.'
|
||||
|
||||
const text = `Hallo,\n\n${isUpdate ? 'ihre Anfrage wurde erfolgreich aktualisiert!' : 'vielen Dank für Ihre Anfrage bei CASPOS Shop!'}\n\n${isUpdate ? 'Neue Anfragedetails' : 'Anfragedetails'}:\n- Anfrage-Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Endkunde: ${details.customerCompanyName}\n${details.totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre ${isUpdate ? 'aktualisierte' : ''} Anfragebestätigung als PDF-Dokument.\n\nPDF herunterladen: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team`
|
||||
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten'
|
||||
const intro = isUpdate
|
||||
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
|
||||
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
|
||||
|
||||
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.itemsDetailsText || ''}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
@@ -24,10 +26,10 @@ export function getOrderEmailTemplate(
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p>
|
||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">${isUpdate ? 'Neue Anfrage-Details' : 'Anfrage-Details'}</h3>
|
||||
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
|
||||
<td style="padding: 4px 0;">${details.orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -35,18 +37,18 @@ export function getOrderEmailTemplate(
|
||||
<td style="padding: 4px 0;">${details.formattedDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
|
||||
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
|
||||
</tr>
|
||||
${details.itemsDetailsHtml || ''}
|
||||
${details.totalDetailsHtml}
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre ${isUpdate ? 'aktualisierte' : ''} Anfragebestätigung als PDF-Dokument.</p>
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="${siteUrl}" style="display: inline-block; background-color: #3b82f6; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: bold; font-size: 14px;">📄 PDF herunterladen</a>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
|
||||
${!isUpdate ? '<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;"><p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>' : ''}
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
|
||||
<a href="${siteUrl}/api/orders/${details.orderNumber}/download" style="color: #3b82f6; text-decoration: underline; font-weight: 500;">Anfragebestätigung PDF herunterladen</a>
|
||||
</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -54,34 +56,95 @@ export function getOrderEmailTemplate(
|
||||
}
|
||||
|
||||
export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) {
|
||||
const text = `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`
|
||||
|
||||
const text = `Hallo,\n\nder Status deiner Anfrage ${orderNumber} hat sich geändert.\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nDein CASPOS Team`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<h2 style="color: #0f172a; margin-bottom: 16px;">Status Ihrer Bestellung hat sich geändert</h2>
|
||||
<h2 style="color: #0f172a; margin-bottom: 16px;">Statusänderung</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status Ihrer Bestellung mit der Nummer <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status deiner Anfrage <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
|
||||
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Alter Status:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Vorher:</td>
|
||||
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Neuer Status:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Aktuell:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Ihr CASPOS Shop-Team</p>
|
||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
export function buildEmailItemsSection(items: any[]) {
|
||||
const groupedItems: { [key: string]: any[] } = {}
|
||||
items.forEach((item: any) => {
|
||||
const devName = item.device_name || 'Kasse 1'
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = []
|
||||
}
|
||||
groupedItems[devName].push(item)
|
||||
})
|
||||
|
||||
let itemsDetailsText = '\nKassen-Konfigurationen:'
|
||||
let itemsDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
|
||||
itemsDetailsText += `\n- Kasse: ${deviceName}`
|
||||
itemsDetailsHtml += `
|
||||
<tr style="background-color: #f1f5f9;">
|
||||
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: ${deviceName}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
devItems.forEach((item: any) => {
|
||||
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
|
||||
itemsDetailsHtml += `
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
|
||||
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
|
||||
</td>
|
||||
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
|
||||
${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1
|
||||
const price = mod.total_price ?? (mod.price * qty)
|
||||
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
|
||||
itemsDetailsHtml += `
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
|
||||
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
|
||||
+${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
text: itemsDetailsText + '\n',
|
||||
html: itemsDetailsHtml
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,26 +74,32 @@ export async function getEndCustomer(id: string): Promise<EndCustomer | null> {
|
||||
* Legt einen neuen Endkunden für den eingeloggten Partner an.
|
||||
* partner_id wird serverseitig aus der Session gesetzt (kein Client-Trust).
|
||||
*/
|
||||
export async function createEndCustomer(formData: EndCustomerFormData): Promise<EndCustomer> {
|
||||
export async function createEndCustomer(formData: EndCustomerFormData, companyId?: string): Promise<EndCustomer> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { data: dbUser, error: dbUserError } = await supabase
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('company_id')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (dbUserError || !dbUser || !dbUser.company_id) {
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const resolvedCompanyId = dbUser?.company_id || companyId
|
||||
|
||||
if (!isAdmin && !resolvedCompanyId) {
|
||||
throw new Error('Kein Unternehmen zugewiesen.')
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
// Admin ohne company_id braucht adminClient (RLS erlaubt kein INSERT ohne passende partner_id)
|
||||
const client = (!dbUser?.company_id && isAdmin) ? createAdminClient() : supabase
|
||||
|
||||
const { data, error } = await client
|
||||
.from('end_customers')
|
||||
.insert([{
|
||||
partner_id: dbUser.company_id,
|
||||
partner_id: resolvedCompanyId || user.id,
|
||||
company_name: formData.company_name,
|
||||
vat_id: formData.vat_id || null,
|
||||
first_name: formData.first_name || null,
|
||||
|
||||
151
shop/lib/actions/licserver-config.ts
Normal file
151
shop/lib/actions/licserver-config.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
|
||||
export interface LicServerConfig {
|
||||
base_url: string | null
|
||||
api_key: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read LicServer config from the settings table.
|
||||
* Falls back to environment variables if DB values are empty.
|
||||
* Returns null config for unauthenticated callers.
|
||||
*/
|
||||
export async function getLicServerConfig(): Promise<LicServerConfig> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Auth guard — never expose config to unauthenticated callers
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { base_url: null, api_key: null }
|
||||
|
||||
const { data } = await supabase
|
||||
.from('settings')
|
||||
.select('licserver_base_url, licserver_api_key')
|
||||
.eq('id', 'licserver')
|
||||
.single()
|
||||
|
||||
return {
|
||||
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
|
||||
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save LicServer config. Admin-only — checks role before writing.
|
||||
*/
|
||||
export async function saveLicServerConfig(
|
||||
base_url: string,
|
||||
api_key: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Auth + role check
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { success: false, error: 'Nicht angemeldet' }
|
||||
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
// Only 'admin' exists in schema — 'superadmin' removed
|
||||
if (!userData || userData.role !== 'admin') {
|
||||
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'licserver',
|
||||
licserver_base_url: base_url.trim() || null,
|
||||
licserver_api_key: api_key.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (error) return { success: false, error: error.message }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection with current config.
|
||||
* Calls GET /api-v1/partners?pageSize=1 and returns true if 200.
|
||||
*/
|
||||
export async function testLicServerConnection(): Promise<{ ok: boolean; message: string }> {
|
||||
const cfg = await getLicServerConfig()
|
||||
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
return { ok: false, message: 'URL oder API-Key nicht konfiguriert' }
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1`, {
|
||||
headers: { 'X-Api-Key': cfg.api_key, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (res.ok) return { ok: true, message: `Verbindung OK (HTTP ${res.status})` }
|
||||
return { ok: false, message: `LicServer antwortete mit HTTP ${res.status}` }
|
||||
} catch (err: any) {
|
||||
return { ok: false, message: `Nicht erreichbar: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System version of getLicServerConfig that bypasses client auth check.
|
||||
* Used internally by automated background jobs / cron routes.
|
||||
*/
|
||||
export async function getLicServerConfigSystem(): Promise<LicServerConfig> {
|
||||
const admin = createAdminClient()
|
||||
|
||||
const { data } = await admin
|
||||
.from('settings')
|
||||
.select('licserver_base_url, licserver_api_key')
|
||||
.eq('id', 'licserver')
|
||||
.single()
|
||||
|
||||
return {
|
||||
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
|
||||
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function lookupLicenseFromLicServer(id: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Nicht autorisiert.')
|
||||
|
||||
const cfg = await getLicServerConfigSystem()
|
||||
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
throw new Error('LicServer ist nicht konfiguriert.')
|
||||
}
|
||||
|
||||
const res = await fetch(`${cfg.base_url}/v1/licenses/${encodeURIComponent(id.trim())}`, {
|
||||
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) {
|
||||
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
return {
|
||||
licenseKey: data.id,
|
||||
status: 'active' as const,
|
||||
product: data.productId || 'Unbekanntes Produkt',
|
||||
edition: data.serialNumber ? `Seriennummer: ${data.serialNumber}` : 'Standard',
|
||||
version: '1.0.0',
|
||||
seats: 1,
|
||||
customer: data.filename || 'Lizenz-Datei',
|
||||
contact: '—',
|
||||
issuedAt: '',
|
||||
expiresAt: '',
|
||||
maintenanceUntil: '',
|
||||
modules: [],
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transfo
|
||||
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { validateWizardSelections } from '@/lib/actions/validation'
|
||||
import { getOrderEmailTemplate, getStatusEmailTemplate } from '@/lib/actions/email-templates'
|
||||
import { getOrderEmailTemplate, getStatusEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates'
|
||||
|
||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
||||
@@ -256,12 +258,16 @@ export async function submitOrder(params: {
|
||||
`
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items)
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
|
||||
|
||||
await sendMail({
|
||||
@@ -292,7 +298,7 @@ export async function submitOrder(params: {
|
||||
|
||||
export async function updateOrderStatus(
|
||||
orderId: string,
|
||||
newStatus: 'pending' | 'active' | 'completed' | 'cancelled'
|
||||
newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
) {
|
||||
const admin = createAdminClient()
|
||||
|
||||
@@ -337,6 +343,7 @@ export async function updateOrderStatus(
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
||||
const newLabel = statusLabelMap[newStatus] || newStatus
|
||||
@@ -345,7 +352,7 @@ export async function updateOrderStatus(
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
|
||||
subject: `Statusänderung Ihrer Anfrage ${orderNumber}`,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html
|
||||
})
|
||||
@@ -576,12 +583,16 @@ export async function updateOrder(
|
||||
`
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items)
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
|
||||
|
||||
await sendMail({
|
||||
|
||||
@@ -206,3 +206,61 @@ export async function transferModules(
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
export async function addProductDependency(
|
||||
productId: string,
|
||||
type: 'requirements' | 'exclusions',
|
||||
dependencyId: string
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: product, error: fetchError } = await supabase
|
||||
.from('products')
|
||||
.select('requirements, exclusions')
|
||||
.eq('id', productId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
|
||||
const currentDeps = (product[type] || []) as string[]
|
||||
if (!currentDeps.includes(dependencyId)) {
|
||||
const updatedDeps = [...currentDeps, dependencyId]
|
||||
const { error: updateError } = await supabase
|
||||
.from('products')
|
||||
.update({ [type]: updatedDeps })
|
||||
.eq('id', productId)
|
||||
if (updateError) throw updateError
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
export async function removeProductDependency(
|
||||
productId: string,
|
||||
type: 'requirements' | 'exclusions',
|
||||
dependencyId: string
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: product, error: fetchError } = await supabase
|
||||
.from('products')
|
||||
.select('requirements, exclusions')
|
||||
.eq('id', productId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
|
||||
const currentDeps = (product[type] || []) as string[]
|
||||
if (currentDeps.includes(dependencyId)) {
|
||||
const updatedDeps = currentDeps.filter(id => id !== dependencyId)
|
||||
const { error: updateError } = await supabase
|
||||
.from('products')
|
||||
.update({ [type]: updatedDeps })
|
||||
.eq('id', productId)
|
||||
if (updateError) throw updateError
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
@@ -7,43 +7,72 @@ export function validateWizardSelections(
|
||||
): void {
|
||||
for (const cat of dbCategories) {
|
||||
const sel = selections[cat.id]
|
||||
if (cat.is_required && (!sel || !sel.productId)) {
|
||||
|
||||
// --- is_required: Pflichtauswahl prüfen ---
|
||||
const hasSelection = sel && (
|
||||
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
|
||||
(!cat.allow_multiselect && sel.productId)
|
||||
)
|
||||
|
||||
if (cat.is_required && !hasSelection) {
|
||||
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
||||
}
|
||||
if (sel?.productId) {
|
||||
const prod = dbProducts.find(p => p.id === sel.productId)
|
||||
if (!prod) {
|
||||
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
||||
|
||||
if (sel) {
|
||||
// --- allow_multiselect = false: keine doppelten IDs erlaubt ---
|
||||
if (!cat.allow_multiselect && sel.productIds && sel.productIds.length > 1) {
|
||||
throw new Error(
|
||||
`Die Kategorie "${cat.name}" erlaubt keine Mehrfachauswahl. Bitte nur ein Produkt wählen.`
|
||||
)
|
||||
}
|
||||
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) {
|
||||
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
||||
}
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel.productIds) {
|
||||
sel.productIds.forEach(pId => {
|
||||
const p = dbProducts.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel.productId) {
|
||||
const p = dbProducts.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
for (const prod of selectedProds) {
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) continue
|
||||
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Product-level constraints validation
|
||||
const selectedProductIds = Object.values(selections)
|
||||
.map(s => s.productId)
|
||||
.filter((id): id is string => !!id)
|
||||
// --- Product-level constraints validation ---
|
||||
const selectedProductIds: string[] = []
|
||||
Object.values(selections).forEach(s => {
|
||||
if (s.productIds && s.productIds.length > 0) {
|
||||
s.productIds.forEach(id => {
|
||||
if (id) selectedProductIds.push(id)
|
||||
})
|
||||
} else if (s.productId) {
|
||||
selectedProductIds.push(s.productId)
|
||||
}
|
||||
})
|
||||
|
||||
for (const prodId of selectedProductIds) {
|
||||
const prod = dbProducts.find(p => p.id === prodId)
|
||||
@@ -72,3 +101,4 @@ export function validateWizardSelections(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
shop/lib/checkout-split.test.ts
Normal file
42
shop/lib/checkout-split.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { splitCart, CartItem } from './checkout-split';
|
||||
|
||||
describe('Checkout Split Logic', () => {
|
||||
it('should place purchase items only into purchase group', () => {
|
||||
const cart: CartItem[] = [
|
||||
{ billingInterval: 'one_time', selections: {} },
|
||||
{ billingType: 'purchase', selections: {} }
|
||||
];
|
||||
|
||||
const { purchaseItems, subscriptionItems } = splitCart(cart);
|
||||
|
||||
expect(purchaseItems.length).toBe(2);
|
||||
expect(subscriptionItems.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should place subscription items only into subscription group', () => {
|
||||
const cart: CartItem[] = [
|
||||
{ billingInterval: 'monthly', selections: {} },
|
||||
{ billingType: 'subscription', selections: {} }
|
||||
];
|
||||
|
||||
const { purchaseItems, subscriptionItems } = splitCart(cart);
|
||||
|
||||
expect(purchaseItems.length).toBe(0);
|
||||
expect(subscriptionItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should split mixed cart into separate purchase and subscription groups', () => {
|
||||
const cart: CartItem[] = [
|
||||
{ billingInterval: 'one_time', selections: {} },
|
||||
{ billingInterval: 'monthly', selections: {} },
|
||||
{ billingType: 'purchase', selections: {} },
|
||||
{ billingType: 'subscription', selections: {} }
|
||||
];
|
||||
|
||||
const { purchaseItems, subscriptionItems } = splitCart(cart);
|
||||
|
||||
expect(purchaseItems.length).toBe(2);
|
||||
expect(subscriptionItems.length).toBe(2);
|
||||
});
|
||||
});
|
||||
17
shop/lib/checkout-split.ts
Normal file
17
shop/lib/checkout-split.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface CartItem {
|
||||
id?: string;
|
||||
billingInterval?: 'one_time' | 'monthly';
|
||||
billingType?: 'purchase' | 'subscription';
|
||||
selections: any;
|
||||
moduleQuantities?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function splitCart(items: CartItem[]) {
|
||||
const purchaseItems = items.filter(
|
||||
item => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||
);
|
||||
const subscriptionItems = items.filter(
|
||||
item => item.billingInterval === 'monthly' || item.billingType === 'subscription'
|
||||
);
|
||||
return { purchaseItems, subscriptionItems };
|
||||
}
|
||||
@@ -99,40 +99,59 @@ export function buildOrderSnapshot(
|
||||
|
||||
for (const cat of categories) {
|
||||
const sel: CategorySelection | undefined = selections[cat.id]
|
||||
if (!sel?.productId) continue
|
||||
if (!sel) continue
|
||||
|
||||
const prod = products.find((p) => p.id === sel.productId)
|
||||
if (!prod) continue
|
||||
|
||||
dominantTaxRate = prod.tax_rate
|
||||
|
||||
const selectedModules = sel.moduleIds
|
||||
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||
.map((mod) => {
|
||||
const qty = moduleQuantities?.[mod.id] || 1
|
||||
return {
|
||||
module_id: mod.id,
|
||||
module_name: mod.name,
|
||||
price: mod.price,
|
||||
quantity: qty,
|
||||
total_price: mod.price * qty,
|
||||
}
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) {
|
||||
sel.productIds.forEach(pId => {
|
||||
const p = products.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel.productId) {
|
||||
const p = products.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||
const itemTotal = prod.base_price + moduleTotal
|
||||
subtotal += itemTotal
|
||||
if (selectedProds.length === 0) continue
|
||||
|
||||
items.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
product_id: prod.id,
|
||||
product_name: prod.name,
|
||||
base_price: prod.base_price,
|
||||
billing_interval: prod.billing_interval,
|
||||
selected_modules: selectedModules,
|
||||
item_total: itemTotal,
|
||||
// Apply free items limit: sort selected products by base_price ascending so that the cheapest are free
|
||||
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
|
||||
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
|
||||
|
||||
sortedProds.forEach((prod, sortedIndex) => {
|
||||
dominantTaxRate = prod.tax_rate
|
||||
|
||||
const selectedModules = sel.moduleIds
|
||||
.map((modId) => prod.modules?.find((m) => m.id === modId))
|
||||
.filter((m): m is NonNullable<typeof m> => !!m)
|
||||
.map((mod) => {
|
||||
const qty = moduleQuantities?.[mod.id] || 1
|
||||
return {
|
||||
module_id: mod.id,
|
||||
module_name: mod.name,
|
||||
price: mod.price,
|
||||
quantity: qty,
|
||||
total_price: mod.price * qty,
|
||||
}
|
||||
})
|
||||
|
||||
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
|
||||
|
||||
// If this product falls under the free limit, set its base price to 0
|
||||
const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price
|
||||
const itemTotal = actualBasePrice + moduleTotal
|
||||
subtotal += itemTotal
|
||||
|
||||
items.push({
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
product_id: prod.id,
|
||||
product_name: prod.name + (actualBasePrice === 0 ? "" : ""),
|
||||
base_price: actualBasePrice,
|
||||
billing_interval: prod.billing_interval,
|
||||
selected_modules: selectedModules,
|
||||
item_total: itemTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,10 +183,14 @@ export function buildOrderSnapshot(
|
||||
multiplier = 0.90
|
||||
}
|
||||
|
||||
// Multiply only one_time item totals by multiplier
|
||||
// Multiply only one_time item totals by multiplier if product allows it
|
||||
items.forEach(item => {
|
||||
if (item.billing_interval === 'one_time') {
|
||||
item.item_total = item.item_total * multiplier
|
||||
const prod = products.find(p => p.id === item.product_id);
|
||||
const allowDiscount = prod ? prod.allow_update_discount !== false : true;
|
||||
if (allowDiscount) {
|
||||
item.item_total = item.item_total * multiplier;
|
||||
}
|
||||
}
|
||||
})
|
||||
subtotal = items.reduce((acc, item) => acc + item.item_total, 0)
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Product = {
|
||||
show_in_abo?: boolean
|
||||
requirements?: string[]
|
||||
exclusions?: string[]
|
||||
allow_update_discount?: boolean
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
@@ -26,6 +27,12 @@ export type Category = {
|
||||
icon?: string | null
|
||||
sort_order: number
|
||||
is_required: boolean
|
||||
allow_multiselect: boolean
|
||||
free_items_limit: number
|
||||
preselect: boolean
|
||||
show_in_kauf?: boolean
|
||||
show_in_abo?: boolean
|
||||
resets_others: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -79,7 +86,8 @@ export type EndCustomer = {
|
||||
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
|
||||
|
||||
export type CategorySelection = {
|
||||
productId: string | null
|
||||
productId: string | null // for backwards compatibility & single select
|
||||
productIds?: string[] // for multi-select categories
|
||||
moduleIds: string[]
|
||||
}
|
||||
|
||||
@@ -127,6 +135,7 @@ export type OrderItem = {
|
||||
billing_interval: BillingInterval
|
||||
selected_modules: OrderModuleSnapshot[]
|
||||
item_total: number
|
||||
device_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +166,7 @@ export type Order = {
|
||||
order_data: OrderSnapshot
|
||||
total_price: number
|
||||
pdf_url: string | null
|
||||
status: 'pending' | 'active' | 'completed' | 'cancelled'
|
||||
status: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
88
shop/lib/types/licserver.ts
Normal file
88
shop/lib/types/licserver.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* TypeScript types generated from the CASPOS LicServer OpenAPI spec v1.
|
||||
* Source: /api-v1/partners endpoints
|
||||
*/
|
||||
|
||||
export interface PartnerDto {
|
||||
id: string // UUID
|
||||
name: string | null
|
||||
email: string | null
|
||||
erpId: string | null
|
||||
}
|
||||
|
||||
export interface PartnerDtoPagedResult {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalCount: number
|
||||
totalPages: number
|
||||
hasNextPage: boolean
|
||||
hasPreviousPage: boolean
|
||||
items: PartnerDto[] | null
|
||||
}
|
||||
|
||||
/** Standard RFC 7807 problem response from LicServer */
|
||||
export interface LicServerProblemDetails {
|
||||
type?: string | null
|
||||
title?: string | null
|
||||
status?: number | null
|
||||
detail?: string | null
|
||||
instance?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CustomerDto {
|
||||
id: string // UUID
|
||||
name: string | null
|
||||
street: string | null
|
||||
houseNumber: string | null
|
||||
houseNumberAddon: string | null
|
||||
zipCode: string | null
|
||||
city: string | null
|
||||
countryCode: string | null
|
||||
email: string | null
|
||||
partnerId: string | null
|
||||
erpId: string | null
|
||||
}
|
||||
|
||||
export interface CustomerDtoPagedResult {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalCount: number
|
||||
totalPages: number
|
||||
hasNextPage: boolean
|
||||
hasPreviousPage: boolean
|
||||
items: CustomerDto[] | null
|
||||
}
|
||||
|
||||
export interface LocationDto {
|
||||
id: string // UUID
|
||||
name: string | null
|
||||
street: string | null
|
||||
houseNumber: string | null
|
||||
houseNumberAddon: string | null
|
||||
countryCode: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
taxNumber: string | null
|
||||
vatIdNumber: string | null
|
||||
}
|
||||
|
||||
export interface CustomerDetailsDto {
|
||||
id: string // UUID
|
||||
name: string | null
|
||||
street: string | null
|
||||
houseNumber: string | null
|
||||
houseNumberAddon: string | null
|
||||
zipCode: string | null
|
||||
city: string | null
|
||||
countryCode: string | null
|
||||
email: string | null
|
||||
partnerId: string | null
|
||||
erpId: string | null
|
||||
partnerName: string | null
|
||||
taxNumber: string | null
|
||||
vatIdNumber: string | null
|
||||
notes: string | null
|
||||
locations: LocationDto[] | null
|
||||
}
|
||||
|
||||
110
shop/lib/wizard-state.test.ts
Normal file
110
shop/lib/wizard-state.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleBaseProductChange, handleModuleToggle } from './wizard-state';
|
||||
import { Product, ProductModule, Category, WizardSelections } from './types';
|
||||
|
||||
describe('Wizard State Management', () => {
|
||||
const mockCategories: Category[] = [
|
||||
{
|
||||
id: 'cat-1',
|
||||
name: 'Kasse',
|
||||
sort_order: 1,
|
||||
is_required: true,
|
||||
allow_multiselect: false,
|
||||
free_items_limit: 0,
|
||||
preselect: false,
|
||||
resets_others: false,
|
||||
created_at: '',
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
name: 'Backoffice',
|
||||
sort_order: 2,
|
||||
is_required: false,
|
||||
allow_multiselect: false, // Radio behavior
|
||||
free_items_limit: 0,
|
||||
preselect: false,
|
||||
resets_others: false,
|
||||
created_at: '',
|
||||
}
|
||||
];
|
||||
|
||||
const mockModules: ProductModule[] = [
|
||||
{
|
||||
id: 'mod-cloud',
|
||||
product_id: 'prod-gastro',
|
||||
name: 'POSCloud',
|
||||
price: 10,
|
||||
requirements: [],
|
||||
exclusions: [],
|
||||
created_at: '',
|
||||
},
|
||||
{
|
||||
id: 'mod-office-1',
|
||||
product_id: 'prod-gastro',
|
||||
name: 'Office Pro',
|
||||
price: 20,
|
||||
requirements: [],
|
||||
exclusions: [],
|
||||
created_at: '',
|
||||
},
|
||||
{
|
||||
id: 'mod-office-2',
|
||||
product_id: 'prod-gastro',
|
||||
name: 'Office Light',
|
||||
price: 5,
|
||||
requirements: [],
|
||||
exclusions: [],
|
||||
created_at: '',
|
||||
}
|
||||
];
|
||||
|
||||
it('should automatically remove excluded modules when switching base product', () => {
|
||||
const currentSelections: WizardSelections = {
|
||||
'cat-2': {
|
||||
productId: null,
|
||||
moduleIds: ['mod-cloud'] // User has POSCloud selected
|
||||
}
|
||||
};
|
||||
|
||||
// Exclusion rule: 'prod-small' excludes 'mod-cloud'
|
||||
const mockExclusions = [
|
||||
{
|
||||
product_id: 'prod-small',
|
||||
excluded_module_id: 'mod-cloud'
|
||||
}
|
||||
];
|
||||
|
||||
const result = handleBaseProductChange(
|
||||
'prod-small',
|
||||
currentSelections,
|
||||
mockModules,
|
||||
[],
|
||||
mockExclusions
|
||||
);
|
||||
|
||||
// POSCloud should be removed
|
||||
expect(result['cat-2'].moduleIds).not.toContain('mod-cloud');
|
||||
});
|
||||
|
||||
it('should respect allow_multiselect = false and only allow one selection in that category', () => {
|
||||
let selections: WizardSelections = {
|
||||
'cat-2': {
|
||||
productId: null,
|
||||
moduleIds: ['mod-office-1']
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle on 'mod-office-2' in a single-select category
|
||||
selections = handleModuleToggle(
|
||||
'mod-office-2',
|
||||
'cat-2',
|
||||
selections,
|
||||
mockCategories,
|
||||
mockModules
|
||||
);
|
||||
|
||||
// Should only contain 'mod-office-2'
|
||||
expect(selections['cat-2'].moduleIds).toContain('mod-office-2');
|
||||
expect(selections['cat-2'].moduleIds).not.toContain('mod-office-1');
|
||||
});
|
||||
});
|
||||
39
shop/lib/wizard-state.ts
Normal file
39
shop/lib/wizard-state.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Product, ProductModule, Category, WizardSelections } from './types';
|
||||
|
||||
/**
|
||||
* Handle toggling of a module selection.
|
||||
* Handles category-specific allow_multiselect rules.
|
||||
*/
|
||||
export function handleModuleToggle(
|
||||
moduleId: string,
|
||||
categoryId: string,
|
||||
currentSelections: WizardSelections,
|
||||
categories: Category[],
|
||||
modules: ProductModule[]
|
||||
): WizardSelections {
|
||||
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
const allowMultiple = category ? category.allow_multiselect : true;
|
||||
|
||||
if (!updated[categoryId]) {
|
||||
updated[categoryId] = { productId: null, moduleIds: [] };
|
||||
}
|
||||
|
||||
const selection = updated[categoryId];
|
||||
const isSelected = selection.moduleIds.includes(moduleId);
|
||||
|
||||
if (isSelected) {
|
||||
// Toggle off
|
||||
selection.moduleIds = selection.moduleIds.filter(id => id !== moduleId);
|
||||
} else {
|
||||
// Toggle on
|
||||
if (!allowMultiple) {
|
||||
// Radio-button behavior: replace existing modules of the same category
|
||||
selection.moduleIds = [moduleId];
|
||||
} else {
|
||||
selection.moduleIds.push(moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
1602
shop/package-lock.json
generated
1602
shop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -51,6 +52,7 @@
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
64
shop/scratch_test.js
Normal file
64
shop/scratch_test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read .env.local
|
||||
const envPath = path.join(__dirname, '.env.local');
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const env = {};
|
||||
envContent.split('\n').forEach(line => {
|
||||
const parts = line.split('=');
|
||||
if (parts.length >= 2) {
|
||||
env[parts[0].trim()] = parts.slice(1).join('=').trim();
|
||||
}
|
||||
});
|
||||
|
||||
const supabaseUrl = env['NEXT_PUBLIC_SUPABASE_URL'];
|
||||
const supabaseKey = env['SUPABASE_SERVICE_ROLE_KEY'];
|
||||
|
||||
async function main() {
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
const { data: licRow, error } = await supabase
|
||||
.from('settings')
|
||||
.select('licserver_base_url, licserver_api_key')
|
||||
.eq('id', 'licserver')
|
||||
.single();
|
||||
|
||||
if (error || !licRow) {
|
||||
console.error("Failed to load licserver config:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const base = 'http://192.168.178.174:9981'; // Target API port 9981
|
||||
const apiKey = licRow.licserver_api_key;
|
||||
|
||||
console.log("Using API Base:", base);
|
||||
console.log("API Key found:", apiKey ? "Yes (length " + apiKey.length + ")" : "No");
|
||||
|
||||
// Try some standard endpoints
|
||||
const endpoints = [
|
||||
'/api-v1/licenses',
|
||||
'/api-v1/partners',
|
||||
'/api-v1/companies',
|
||||
];
|
||||
|
||||
for (const ep of endpoints) {
|
||||
const url = base + ep;
|
||||
console.log(`\nFetching: ${url}`);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey || '',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
console.log(`Status: ${res.status} ${res.statusText}`);
|
||||
const text = await res.text();
|
||||
console.log(`Response (first 500 chars):`, text.substring(0, 500));
|
||||
} catch (e) {
|
||||
console.error(`Error fetching ${url}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -24,28 +24,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
|
||||
partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
-- 4. Recreate RLS policies for licenses (since partner_id now matches company_id)
|
||||
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
|
||||
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.end_customers c
|
||||
WHERE c.id = licenses.end_customer_id
|
||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
|
||||
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.end_customers c
|
||||
WHERE c.id = licenses.end_customer_id
|
||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
)
|
||||
);
|
||||
-- 4. Licenses policy recreation is omitted since licenses table is dropped.
|
||||
|
||||
-- Reload Schema Cache
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
@@ -33,31 +33,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
|
||||
OR partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
-- 4. Recreate licenses SELECT RLS policy to allow admin bypass
|
||||
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
|
||||
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
|
||||
FOR SELECT
|
||||
USING (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.end_customers c
|
||||
WHERE c.id = licenses.end_customer_id
|
||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
-- 5. Recreate licenses INSERT RLS policy to allow admin bypass
|
||||
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
|
||||
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.end_customers c
|
||||
WHERE c.id = licenses.end_customer_id
|
||||
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
|
||||
)
|
||||
);
|
||||
-- 4 & 5. Licenses policy updates are omitted since licenses table is dropped.
|
||||
|
||||
-- Reload Schema Cache
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Migration: Remove unused pos_modules and licenses tables and reference functions
|
||||
-- 1. Drop the tables (cascade to clean up RLS policies automatically)
|
||||
DROP TABLE IF EXISTS public.licenses CASCADE;
|
||||
DROP TABLE IF EXISTS public.pos_modules CASCADE;
|
||||
|
||||
-- 2. Update database integrity function to remove pos_modules and licenses checks
|
||||
CREATE OR REPLACE FUNCTION public.check_database_integrity()
|
||||
RETURNS jsonb
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
tables_to_check text[] := ARRAY[
|
||||
'users',
|
||||
'settings',
|
||||
'companies',
|
||||
'categories',
|
||||
'products',
|
||||
'product_modules',
|
||||
'orders',
|
||||
'profiles',
|
||||
'end_customers'
|
||||
];
|
||||
t text;
|
||||
t_exists boolean;
|
||||
t_count bigint;
|
||||
tables_result jsonb := jsonb_build_object();
|
||||
result jsonb;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY tables_to_check LOOP
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = t
|
||||
) INTO t_exists;
|
||||
|
||||
t_count := 0;
|
||||
IF t_exists THEN
|
||||
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO t_count;
|
||||
END IF;
|
||||
|
||||
tables_result := tables_result || jsonb_build_object(t, jsonb_build_object('exists', t_exists, 'count', t_count));
|
||||
END LOOP;
|
||||
|
||||
result := jsonb_build_object(
|
||||
'tables', tables_result,
|
||||
'errors', jsonb_build_object(
|
||||
'foreign_keys', 0
|
||||
)
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 3. Update database repair schema function to remove creation of pos_modules and licenses
|
||||
CREATE OR REPLACE FUNCTION public.repair_database_schema()
|
||||
RETURNS jsonb
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
repaired_tables text[] := '{}';
|
||||
result jsonb;
|
||||
BEGIN
|
||||
-- 1. profiles table
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN
|
||||
CREATE TABLE public.profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
company_name TEXT,
|
||||
vat_id TEXT,
|
||||
address TEXT,
|
||||
city TEXT,
|
||||
zip_code TEXT,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
repaired_tables := array_append(repaired_tables, 'profiles');
|
||||
END IF;
|
||||
|
||||
-- 2. end_customers table
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') THEN
|
||||
CREATE TABLE public.end_customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
company_name TEXT NOT NULL,
|
||||
vat_id TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
street TEXT,
|
||||
zip TEXT,
|
||||
city TEXT,
|
||||
is_anonymized BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
repaired_tables := array_append(repaired_tables, 'end_customers');
|
||||
END IF;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.end_customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Recreate RLS Policies
|
||||
DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles;
|
||||
CREATE POLICY "Users can view their own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
|
||||
DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles;
|
||||
CREATE POLICY "Users can update their own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
|
||||
DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles;
|
||||
CREATE POLICY "Users can insert their own profile" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id);
|
||||
|
||||
result := jsonb_build_object(
|
||||
'repaired_tables', repaired_tables
|
||||
);
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Reload Schema Cache
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add multi-select and free items limit to categories table
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS allow_multiselect BOOLEAN DEFAULT false NOT NULL;
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS free_items_limit INTEGER DEFAULT 0 NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add preselect column to categories table
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS preselect BOOLEAN DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add show_in_kauf and show_in_abo columns to categories table
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS show_in_kauf BOOLEAN DEFAULT true NOT NULL;
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS show_in_abo BOOLEAN DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add resets_others column to categories table
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS resets_others BOOLEAN DEFAULT false NOT NULL;
|
||||
107
shop/supabase/migrations/20260709220000_consolidated_schema.sql
Normal file
107
shop/supabase/migrations/20260709220000_consolidated_schema.sql
Normal file
@@ -0,0 +1,107 @@
|
||||
-- Migration: Consolidated Schema for CASPOS
|
||||
-- Purpose: Complete definition of core webshop structures, global rules, and order split support.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.companies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
street TEXT,
|
||||
zip TEXT,
|
||||
city TEXT,
|
||||
email TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.users (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'partner' CHECK (role IN ('partner', 'admin')),
|
||||
company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.end_customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
company_name TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
street TEXT,
|
||||
zip TEXT,
|
||||
city TEXT,
|
||||
email TEXT,
|
||||
bank_iban TEXT,
|
||||
bank_bic TEXT,
|
||||
bank_name TEXT,
|
||||
bank_owner TEXT,
|
||||
is_anonymized BOOLEAN DEFAULT false NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
is_required BOOLEAN DEFAULT false NOT NULL,
|
||||
allow_multiple BOOLEAN DEFAULT false NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
show_in_branches BOOLEAN DEFAULT true NOT NULL,
|
||||
preselect BOOLEAN DEFAULT false NOT NULL,
|
||||
resets_others BOOLEAN DEFAULT false NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
base_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
|
||||
tax_rate DECIMAL(5,2) DEFAULT 19.00 NOT NULL,
|
||||
billing_interval TEXT DEFAULT 'monthly' NOT NULL CHECK (billing_interval IN ('one_time', 'monthly')),
|
||||
show_in_branches BOOLEAN DEFAULT true NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.modules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
|
||||
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
|
||||
name TEXT NOT NULL,
|
||||
price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
|
||||
has_quantity BOOLEAN DEFAULT false NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.global_inclusions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trigger_product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
|
||||
included_module_id UUID NOT NULL REFERENCES public.modules(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE (trigger_product_id, included_module_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.global_exclusions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
|
||||
excluded_product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
|
||||
excluded_module_id UUID REFERENCES public.modules(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
CHECK (
|
||||
(excluded_product_id IS NOT NULL AND excluded_module_id IS NULL) OR
|
||||
(excluded_product_id IS NULL AND excluded_module_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.orders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
company_id UUID REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||
end_customer_id UUID REFERENCES public.end_customers(id) ON DELETE RESTRICT,
|
||||
order_number TEXT NOT NULL UNIQUE,
|
||||
order_hash TEXT,
|
||||
type TEXT NOT NULL CHECK (type IN ('purchase', 'subscription')),
|
||||
payment_method TEXT NOT NULL,
|
||||
total_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
|
||||
customer_snapshot JSONB NOT NULL,
|
||||
order_snapshot JSONB NOT NULL,
|
||||
pdf_url TEXT,
|
||||
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'active', 'completed', 'cancelled')),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Migration: Secure User Roles from Self-Escalation
|
||||
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers.
|
||||
|
||||
-- Ensure RLS is enabled on users
|
||||
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy to allow users to view their own records
|
||||
CREATE POLICY select_own_user ON public.users
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (auth.uid() = id);
|
||||
|
||||
-- Policy to allow admins to view all users
|
||||
CREATE POLICY select_all_users_for_admin ON public.users
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
|
||||
);
|
||||
|
||||
-- Trigger to prevent any role updates to 'admin' from unauthorized users
|
||||
CREATE OR REPLACE FUNCTION check_user_role_escalation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only allow changes to the role column if executed by the service_role
|
||||
IF (TG_OP = 'UPDATE' AND OLD.role IS DISTINCT FROM NEW.role) OR (TG_OP = 'INSERT') THEN
|
||||
IF current_setting('role', true) <> 'service_role' THEN
|
||||
-- Partners cannot upgrade themselves or others to admin
|
||||
IF NEW.role = 'admin' THEN
|
||||
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
DROP TRIGGER IF EXISTS enforce_role_protection ON public.users;
|
||||
CREATE TRIGGER enforce_role_protection
|
||||
BEFORE INSERT OR UPDATE ON public.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION check_user_role_escalation();
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration: Fix Users RLS Recursion and Admin Lockout
|
||||
-- Purpose: Implement a security definer function to check admin status, avoiding infinite recursion on public.users.
|
||||
|
||||
-- 1. Create helper function to check admin role bypassing RLS (SECURITY DEFINER)
|
||||
CREATE OR REPLACE FUNCTION public.is_admin(user_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = user_id AND role = 'admin'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 2. Drop the recursive policy on public.users
|
||||
DROP POLICY IF EXISTS select_all_users_for_admin ON public.users;
|
||||
|
||||
-- 3. Re-create the policy using the non-recursive helper function
|
||||
CREATE POLICY select_all_users_for_admin ON public.users
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.is_admin(auth.uid()));
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration: Add missing payment_method column to orders table
|
||||
-- Purpose: Add payment_method column to public.orders and reload Schema Cache.
|
||||
|
||||
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS payment_method TEXT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Migration: Add missing type and payment_method columns to orders table
|
||||
-- Purpose: Ensure physical columns type and payment_method exist on public.orders for checkout split support.
|
||||
|
||||
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'purchase' CHECK (type IN ('purchase', 'subscription'));
|
||||
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS payment_method TEXT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration: Add allow_update_discount to categories
|
||||
-- Purpose: Add a boolean flag to categories to control if their products are eligible for the update discount.
|
||||
|
||||
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS allow_update_discount BOOLEAN DEFAULT true NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Migration: Move allow_update_discount from categories to products
|
||||
-- Purpose: Control update discount eligibility on product level rather than category level.
|
||||
|
||||
ALTER TABLE public.categories DROP COLUMN IF EXISTS allow_update_discount;
|
||||
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS allow_update_discount BOOLEAN DEFAULT true NOT NULL;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Add LicServer configuration columns to settings table
|
||||
ALTER TABLE public.settings
|
||||
ADD COLUMN IF NOT EXISTS licserver_base_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS licserver_api_key TEXT;
|
||||
|
||||
-- Ensure the licserver config row exists (upsert with empty defaults)
|
||||
INSERT INTO public.settings (id, licserver_base_url, licserver_api_key)
|
||||
VALUES ('licserver', NULL, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration: Restrict settings table write access to admins only
|
||||
-- Previously any authenticated user could write to settings (including licserver_api_key).
|
||||
-- This fixes the RLS policy to only allow admins to write.
|
||||
|
||||
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
|
||||
|
||||
-- Admins can write all settings
|
||||
CREATE POLICY "Only admins can write settings" ON public.settings
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = auth.uid() AND role = 'admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.users
|
||||
WHERE id = auth.uid() AND role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- All authenticated users can still READ settings (needed for proxy routes to load licserver config)
|
||||
-- The READ policy remains: "Allow authenticated read on settings"
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
8
shop/vercel.json
Normal file
8
shop/vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/sync-companies",
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
shop/vitest.config.ts
Normal file
14
shop/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user