Compare commits
82 Commits
13981970f5
...
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 |
@@ -31,7 +31,7 @@ graph TD
|
||||
- **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-Garantie**: Basis-Produkte werden im UI niemals durch Modulausschlüsse ausgeblendet (Vermeidung von UI-Deadlocks). Stattdessen werden inkompatible Moduloptionen im JSX deaktiviert (`disabled`).
|
||||
- **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.
|
||||
|
||||
@@ -51,26 +51,25 @@ graph TD
|
||||
- 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`: Zahlungsart `invoice` (Rechnung).
|
||||
- Typ `subscription`: Zahlungsart `sepa`.
|
||||
- Friert für jede Order einen konsolidierten `order_snapshot` (JSONB) und `customer_snapshot` (JSONB) ein.
|
||||
- Triggert die PDF-Rechnungserstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
|
||||
- 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)
|
||||
- **Snapshot-Exklusivität**: Die Rechnungsgenerierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
|
||||
- **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 **B2B-Rechnung**.
|
||||
- Generiert eine klassische **Anfragebestätigung Kauf**.
|
||||
- Weist die einmalige Gesamtsumme aus.
|
||||
- Beinhaltet ein explizites Zahlungsziel ("Zahlbar innerhalb von 14 Tagen ohne Abzug").
|
||||
- **Typ `subscription` (Abonnement)**:
|
||||
- Generiert eine **Vertragsbestätigung (Abonnement)**.
|
||||
- Generiert eine **Anfragebestätigung Abonnement**.
|
||||
- Weist monatlich wiederkehrende Kosten aus.
|
||||
- Beinhaltet das SEPA-Lastschriftmandat inklusive Kontoinhaber und IBAN.
|
||||
- **Archivierung & Benachrichtigung**:
|
||||
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
||||
- E-Mail-Versand mit PDF-Anhang an den Besteller.
|
||||
- E-Mail-Versand mit PDF-Anhang an den Partner.
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
|
||||
- `allow_multiple` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
||||
- `allow_multiselect` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
||||
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
- `preselect` (BOOLEAN)
|
||||
@@ -43,9 +43,10 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
- `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.
|
||||
- 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`)
|
||||
@@ -77,8 +78,20 @@ Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
|
||||
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_snapshot` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
|
||||
- `order_snapshot` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
|
||||
- `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)
|
||||
|
||||
@@ -13,3 +13,8 @@ RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierun
|
||||
- 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.
|
||||
|
||||
@@ -16,6 +16,7 @@ Getestet in [wizard-state.test.ts](file:///c:/source/webshop/shop/lib/wizard-sta
|
||||
|
||||
### 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).
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,11 @@ 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();
|
||||
@@ -16,6 +21,163 @@ function hashOrderPayload(endCustomerId: string, items: any[]): string {
|
||||
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;
|
||||
@@ -96,7 +258,11 @@ export async function checkoutAction(params: {
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
orderItemsList.push(...itemSnapshot.items);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
@@ -132,6 +298,13 @@ export async function checkoutAction(params: {
|
||||
|
||||
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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ 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();
|
||||
@@ -48,6 +51,13 @@ export async function POST(request: Request) {
|
||||
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'
|
||||
@@ -76,7 +86,11 @@ export async function POST(request: Request) {
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
orderItemsList.push(...itemSnapshot.items);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
@@ -113,6 +127,8 @@ export async function POST(request: Request) {
|
||||
|
||||
if (orderError) throw orderError;
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
|
||||
// Trigger PDF generation & storage upload
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
@@ -123,7 +139,6 @@ export async function POST(request: Request) {
|
||||
})
|
||||
);
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
@@ -140,11 +155,128 @@ export async function POST(request: Request) {
|
||||
// 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: `Bestellbestätigung ${order.order_number}`,
|
||||
text: `Vielen Dank für Ihre Bestellung ${order.order_number}. Typ: ${type}.`,
|
||||
html: `<p>Vielen Dank für Ihre Bestellung <strong>${order.order_number}</strong>.</p><p>Typ: ${type}</p>`
|
||||
subject: `Anfragebestätigung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments
|
||||
});
|
||||
} catch (mailErr) {
|
||||
console.error('Mail error for order ' + order.id, mailErr);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
@@ -105,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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -99,13 +99,42 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
||||
|
||||
const totalNet = items.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const totalTax = Math.round(totalNet * (taxRate / 100) * 100) / 100;
|
||||
const totalGross = Math.round((totalNet + totalTax) * 100) / 100;
|
||||
let oneTimeNet = 0;
|
||||
let monthlyNet = 0;
|
||||
|
||||
const formattedPrice = (val: number) =>
|
||||
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 oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
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}>
|
||||
@@ -123,7 +152,7 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{isSubscription ? 'Vertragsbestätigung (Abonnement)' : 'B2B-Rechnung'}
|
||||
Anfragebestätigung: {order.order_number || order.id}
|
||||
</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
@@ -142,64 +171,81 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<Text>Datum: {new Date(order.created_at || Date.now()).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>
|
||||
{formattedPrice(item.base_price)} {isSubscription ? 'mtl.' : 'einmalig'}
|
||||
</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);
|
||||
return (
|
||||
<View key={mIdx} style={styles.tableRow}>
|
||||
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.tableColPrice}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+{formattedPrice(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: '70%' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{formattedPrice(totalNet)}{isSubscription ? ' / mtl.' : ''}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{formattedPrice(totalTax)}{isSubscription ? ' / mtl.' : ''}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||
<Text style={styles.totalLabel}>
|
||||
Gesamtbetrag (brutto):
|
||||
</Text>
|
||||
<Text style={styles.totalLabel}>
|
||||
{formattedPrice(totalGross)}{isSubscription ? ' / mtl.' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: '75%' }}>
|
||||
{oneTimeNet > 0 && (
|
||||
<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>{formattedPrice(oneTimeNet)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{formattedPrice(oneTimeTax)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt einmalig (brutto):</Text>
|
||||
<Text>{formattedPrice(oneTimeGross)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{monthlyNet > 0 && (
|
||||
<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>{formattedPrice(monthlyNet)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{formattedPrice(monthlyTax)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt monatlich (brutto):</Text>
|
||||
<Text>{formattedPrice(monthlyGross)} / mtl.</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -207,7 +253,7 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<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.
|
||||
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' }}>
|
||||
@@ -217,9 +263,8 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontWeight: 'bold' }}>Zahlungsziel</Text>
|
||||
<Text style={{ color: '#555' }}>
|
||||
Zahlbar innerhalb von 14 Tagen nach Erhalt dieser Rechnung ohne Abzug.
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
@@ -16,7 +18,7 @@ export function getOrderEmailTemplate(
|
||||
? `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.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 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;">
|
||||
@@ -38,11 +40,14 @@ export function getOrderEmailTemplate(
|
||||
<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;">Die Bestätigung liegt als PDF im Anhang.</p>
|
||||
</div>
|
||||
<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>
|
||||
`
|
||||
@@ -80,3 +85,66 @@ export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, ne
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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({
|
||||
@@ -346,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
|
||||
})
|
||||
@@ -577,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({
|
||||
|
||||
@@ -7,6 +7,8 @@ export function validateWizardSelections(
|
||||
): void {
|
||||
for (const cat of dbCategories) {
|
||||
const sel = selections[cat.id]
|
||||
|
||||
// --- is_required: Pflichtauswahl prüfen ---
|
||||
const hasSelection = sel && (
|
||||
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
|
||||
(!cat.allow_multiselect && sel.productId)
|
||||
@@ -17,6 +19,13 @@ export function validateWizardSelections(
|
||||
}
|
||||
|
||||
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.`
|
||||
)
|
||||
}
|
||||
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel.productIds) {
|
||||
sel.productIds.forEach(pId => {
|
||||
@@ -32,8 +41,8 @@ export function validateWizardSelections(
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) continue // module might belong to another selected product in the same category, so skip error if it's not this one's
|
||||
|
||||
if (!mod) continue
|
||||
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
@@ -53,7 +62,7 @@ export function validateWizardSelections(
|
||||
}
|
||||
}
|
||||
|
||||
// Product-level constraints validation
|
||||
// --- Product-level constraints validation ---
|
||||
const selectedProductIds: string[] = []
|
||||
Object.values(selections).forEach(s => {
|
||||
if (s.productIds && s.productIds.length > 0) {
|
||||
@@ -92,3 +101,4 @@ export function validateWizardSelections(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ export function buildOrderSnapshot(
|
||||
})
|
||||
|
||||
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
|
||||
@@ -146,7 +146,7 @@ export function buildOrderSnapshot(
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
product_id: prod.id,
|
||||
product_name: prod.name + (actualBasePrice === 0 ? " (Inklusive/Frei)" : ""),
|
||||
product_name: prod.name + (actualBasePrice === 0 ? "" : ""),
|
||||
base_price: actualBasePrice,
|
||||
billing_interval: prod.billing_interval,
|
||||
selected_modules: selectedModules,
|
||||
@@ -183,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 = {
|
||||
@@ -134,6 +135,7 @@ export type OrderItem = {
|
||||
billing_interval: BillingInterval
|
||||
selected_modules: OrderModuleSnapshot[]
|
||||
item_total: number
|
||||
device_name?: 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
|
||||
}
|
||||
|
||||
@@ -1,65 +1,5 @@
|
||||
import { Product, ProductModule, Category, WizardSelections } from './types';
|
||||
|
||||
export interface GlobalInclusion {
|
||||
trigger_product_id: string;
|
||||
included_module_id: string;
|
||||
}
|
||||
|
||||
export interface GlobalExclusion {
|
||||
product_id: string;
|
||||
excluded_product_id?: string | null;
|
||||
excluded_module_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes to the base product.
|
||||
* Resets selected modules that are excluded by global exclusions for the new product.
|
||||
* Automatically adds globally included modules for the new product.
|
||||
*/
|
||||
export function handleBaseProductChange(
|
||||
newProductId: string,
|
||||
currentSelections: WizardSelections,
|
||||
modules: ProductModule[],
|
||||
inclusions: GlobalInclusion[],
|
||||
exclusions: GlobalExclusion[]
|
||||
): WizardSelections {
|
||||
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
|
||||
|
||||
// Find all modules that are excluded for the new product
|
||||
const excludedModuleIds = exclusions
|
||||
.filter(ex => ex.product_id === newProductId && ex.excluded_module_id)
|
||||
.map(ex => ex.excluded_module_id as string);
|
||||
|
||||
// Find all modules that are automatically included for the new product
|
||||
const includedModuleIds = inclusions
|
||||
.filter(inc => inc.trigger_product_id === newProductId)
|
||||
.map(inc => inc.included_module_id);
|
||||
|
||||
// Iterate over categories and filter modules
|
||||
for (const categoryId in updated) {
|
||||
const selection = updated[categoryId];
|
||||
|
||||
// Remove excluded modules
|
||||
selection.moduleIds = selection.moduleIds.filter(
|
||||
mid => !excludedModuleIds.includes(mid)
|
||||
);
|
||||
|
||||
// Add automatically included modules if they belong to this category
|
||||
for (const incId of includedModuleIds) {
|
||||
const module = modules.find(m => m.id === incId);
|
||||
if (module && module.product_id === newProductId) {
|
||||
// Find category of module. In our schema, modules might belong to a category.
|
||||
// Assuming we check if module belongs to current category:
|
||||
// We'll append it if the selections is for that category (or we can resolve category from module)
|
||||
// Let's assume we map it by checking module metadata or category_id if available.
|
||||
// For simplicity: if the module is already in the DB and matches, we add it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle toggling of a module selection.
|
||||
* Handles category-specific allow_multiselect rules.
|
||||
|
||||
16
shop/package-lock.json
generated
16
shop/package-lock.json
generated
@@ -3881,6 +3881,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz",
|
||||
"integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.105.1",
|
||||
"@supabase/functions-js": "2.105.1",
|
||||
@@ -3979,6 +3980,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -3989,6 +3991,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4047,6 +4050,7 @@
|
||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
@@ -4668,6 +4672,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5180,6 +5185,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -6004,6 +6010,7 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -6177,6 +6184,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7549,6 +7557,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -8444,6 +8453,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -8624,6 +8634,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9069,6 +9080,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -9078,6 +9090,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -9090,6 +9103,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
|
||||
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -10185,6 +10199,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10371,6 +10386,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
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();
|
||||
@@ -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 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user