Compare commits
192 Commits
90a5e17f2b
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cf32591f | ||
|
|
e78fe7c4b8 | ||
|
|
ec11ad8498 | ||
|
|
40b63b3fa9 | ||
|
|
facf481fdb | ||
|
|
5f29bb68a4 | ||
|
|
404e7cd314 | ||
|
|
2fc4e50ae1 | ||
|
|
694e8dfbc1 | ||
|
|
2a1897141e | ||
|
|
2b1bb77673 | ||
|
|
b692e6d4cf | ||
|
|
0c99da2eeb | ||
|
|
f68d67de43 | ||
|
|
073dc99860 | ||
|
|
af501d18c7 | ||
|
|
49a7a0768d | ||
|
|
e0d9c1d20e | ||
|
|
5ea0719225 | ||
|
|
3545b77803 | ||
|
|
fff40a68fe | ||
|
|
ea1906f9e4 | ||
|
|
9dbbbb8b31 | ||
|
|
409466af9b | ||
|
|
8f3d6e5569 | ||
|
|
8588eda3c9 | ||
|
|
8a4dbbaf14 | ||
|
|
037e6d749a | ||
|
|
1c8de6e895 | ||
|
|
4296a9106f | ||
|
|
c27cb858c1 | ||
|
|
a7816ba3c4 | ||
|
|
03087f4fbb | ||
|
|
f6760d30a4 | ||
|
|
682863ab26 | ||
|
|
9ecdc645e8 | ||
|
|
009b167cbd | ||
|
|
c1efc7916f | ||
|
|
d066316b67 | ||
|
|
b2a5f7cc64 | ||
|
|
9bde60553e | ||
|
|
16ca9611d7 | ||
|
|
5561dfa708 | ||
|
|
447f0a407c | ||
|
|
386527a35c | ||
|
|
982897a808 | ||
|
|
2f32d80e9e | ||
|
|
4598233cf4 | ||
|
|
edb5873e33 | ||
|
|
9593cbdb79 | ||
|
|
0404a1dd83 | ||
|
|
6eeb56d62a | ||
|
|
17e5b27dfc | ||
|
|
2f2cba1687 | ||
|
|
6748a3ffad | ||
|
|
18228fa8b4 | ||
|
|
2750898524 | ||
|
|
f40268992d | ||
|
|
b72e16706e | ||
|
|
9078b5631a | ||
|
|
1c67cd46bb | ||
|
|
5b9a0c609d | ||
|
|
f500a63e97 | ||
|
|
46699e8bf3 | ||
|
|
866c5522f2 | ||
|
|
1478c61ea8 | ||
|
|
7346b42ff3 | ||
|
|
bc02a6d000 | ||
|
|
883b607482 | ||
|
|
497d95d849 | ||
|
|
c3e81ee49e | ||
|
|
ddc7a03a0b | ||
|
|
e09c247f43 | ||
|
|
4b8dfd5b20 | ||
|
|
ea64ce9ef0 | ||
|
|
407484f69d | ||
|
|
73e046e959 | ||
|
|
3ba7c91e1f | ||
|
|
6177c0cde1 | ||
|
|
696528283a | ||
|
|
cab64028f6 | ||
|
|
8b698e42ec | ||
|
|
1c9ae2ec31 | ||
|
|
af261ce923 | ||
|
|
60b9bd89ef | ||
|
|
f9cb3c0685 | ||
|
|
2f63d716f2 | ||
|
|
1aa694a665 | ||
|
|
ed654f542c | ||
|
|
be3b0f615d | ||
|
|
169afb6538 | ||
|
|
b3ae720136 | ||
|
|
d02703af8b | ||
|
|
abd8f54ef7 | ||
|
|
c08237aa73 | ||
|
|
6a95896361 | ||
|
|
b40f8018f7 | ||
|
|
ec0405a901 | ||
|
|
83d9b796c6 | ||
|
|
a94f883ab6 | ||
|
|
ec264e2680 | ||
|
|
21e01b1b24 | ||
|
|
304d25f202 | ||
|
|
a2305319e1 | ||
|
|
70ed890702 | ||
|
|
b978a6bc64 | ||
|
|
a43d598f54 | ||
|
|
0e4495292d | ||
|
|
c6190b34a8 | ||
|
|
623dab192b | ||
|
|
c5285dc907 | ||
|
|
6cd5eb917c | ||
|
|
061147701e | ||
|
|
dfdb5d9024 | ||
|
|
66d5d8fc98 | ||
|
|
f130f24c86 | ||
|
|
06cba405f3 | ||
|
|
b8820f59b7 | ||
|
|
1c31918768 | ||
|
|
086a893e19 | ||
|
|
61adf76da5 | ||
|
|
2497063e82 | ||
|
|
0a60526800 | ||
|
|
fef1ccd0f5 | ||
|
|
3200784c51 | ||
|
|
acd8d6575c | ||
|
|
3db3606f17 | ||
|
|
f1877cf893 | ||
|
|
ac60995c1d | ||
|
|
07208fcad0 | ||
|
|
8b4fd5f02e | ||
|
|
86b63ce77e | ||
|
|
2b372be228 | ||
|
|
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 |
18
.agents/agents.md
Normal file
18
.agents/agents.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Antigravity Agent Configuration (Project Rocky)
|
||||
|
||||
## Agent: Rocky_Manager
|
||||
- **Role**: Hauptkoordinator des Rocky-Projekts.
|
||||
- **Responsibility**: Verteilt Aufgaben an Spezialisten basierend auf den Skill-Anforderungen.
|
||||
|
||||
## Agent: Dokumenten_Spezialist
|
||||
- **Role**: Verantwortlich für Exporte und Berichte.
|
||||
- **Assigned_Skills**: [".agents/skills/pdf_skill.md"]
|
||||
|
||||
## Agent: Kommunikations_Spezialist
|
||||
- **Role**: Verantwortlich für Benachrichtigungen und Kundenkontakt.
|
||||
- **Assigned_Skills**: [".agents/skills/email_skill.md"]
|
||||
|
||||
## Agent: Compliance_Supervisor
|
||||
- **Role**: Prüft den geschriebenen Code auf Datenschutzfehler.
|
||||
- **Assigned_Skills**: [".agents/skills/dsgvo_skill.md"]
|
||||
- **Rule**: "Blockiere jeden Git-Commit, wenn der Code gegen die DSGVO-Vorgaben verstößt."
|
||||
6
.agents/skills/dsgvo_skill.md
Normal file
6
.agents/skills/dsgvo_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: DSGVO & Datenschutz-Compliance (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Daten-Minimierung**: Spezialisten dürfen personenbezogene Daten (wie E-Mail-Adressen oder Klarnamen) niemals in Log-Dateien oder im Git-Verlauf speichern.
|
||||
2. **Verschlüsselung**: Daten im Rocky-Projekt müssen "at rest" und "in transit" (mindestens TLS 1.3) verschlüsselt werden.
|
||||
3. **Anonymisierung**: Für Testzwecke im Terminal-Subagent dürfen nur synthetische Dummy-Daten verwendet werden. Keine echten Kundendaten nutzen!
|
||||
6
.agents/skills/email_skill.md
Normal file
6
.agents/skills/email_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: E-Mail-Kommunikation (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Tonalität**: Professionell, höflich und im Corporate Design des Rocky-Projekts.
|
||||
2. **Sicherheit**: Sende niemals Passwörter, Token oder unverschlüsselte API-Keys per E-Mail.
|
||||
3. **Fallbacks**: Wenn ein E-Mail-Versand fehlschlägt, schreibe den Fehler sofort in die lokale Log-Datei `rocky_mail_errors.log` und benachrichtige den Manager-Agenten.
|
||||
@@ -53,7 +53,7 @@ graph TD
|
||||
- Erstellt separate Orders in der Datenbank:
|
||||
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
|
||||
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
|
||||
- Friert für jede Order einen konsolidierten `order_snapshot` (JSONB) und `customer_snapshot` (JSONB) ein.
|
||||
- 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).
|
||||
|
||||
---
|
||||
@@ -63,10 +63,10 @@ graph TD
|
||||
- **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-Angebot**.
|
||||
- Generiert eine klassische **Anfragebestätigung Kauf**.
|
||||
- Weist die einmalige Gesamtsumme aus.
|
||||
- **Typ `subscription` (Abonnement)**:
|
||||
- Generiert eine **Angebotsbestätigung Abonnement**.
|
||||
- Generiert eine **Anfragebestätigung Abonnement**.
|
||||
- Weist monatlich wiederkehrende Kosten aus.
|
||||
- **Archivierung & Benachrichtigung**:
|
||||
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
||||
|
||||
@@ -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,6 +43,7 @@ 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. Repräsentiert im TypeScript-Code durch das Interface `ProductModule`.
|
||||
@@ -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)
|
||||
|
||||
7
.agents/skills/lucide-icons-guide/skill.md
Normal file
7
.agents/skills/lucide-icons-guide/skill.md
Normal file
@@ -0,0 +1,7 @@
|
||||
skill: lucide-icons-guide
|
||||
description: Integration, Auswahl und Optimierung von Lucide React Icons (lucide.dev) für Next.js 15 (RSC/Server Actions) und Tailwind CSS.
|
||||
rules:
|
||||
- Importiere Icons direkt aus `lucide-react` (z. B. `import { Package, ShieldCheck, ShoppingCart } from 'lucide-react'`).
|
||||
- Achte bei React Server Components (RSC) darauf, dass Icons performant gerendert und keine unnötigen Client-Side Bundles erzeugt werden.
|
||||
- Verwende konsistente Größen (`size={18}` oder Tailwind `w-5 h-5`) und Stile (z. B. `strokeWidth={1.75}`) passend zum B2B-Design.
|
||||
- Nutze dynamische Icon-Name-Renderings nur über eine typsichere Map/Registry, um Unused-Code-Elimination (Tree Shaking) zu gewährleisten.
|
||||
6
.agents/skills/pdf_skill.md
Normal file
6
.agents/skills/pdf_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: PDF-Verarbeitung & Generierung (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Formatierung**: Nutze ausschließlich barrierefreie PDF/A-Formate für Rechnungen und Berichte im Rocky-Projekt.
|
||||
2. **Metadaten**: Jedes generierte PDF muss automatisch den Projektnamen `Rocky` und einen eindeutigen Zeitstempel im Datei-Header enthalten.
|
||||
3. **Validierung**: Vor dem Speichern muss der Agent prüfen, ob alle Textfelder innerhalb der Seitenränder liegen (kein Textumbruch-Fehler).
|
||||
@@ -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)
|
||||
|
||||
@@ -50,7 +50,6 @@ jobs:
|
||||
docker builder prune -f
|
||||
working-directory: .
|
||||
|
||||
|
||||
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
|
||||
- name: Deploy to Proxmox LXC via SSH
|
||||
uses: appleboy/ssh-action@v0.1.5
|
||||
@@ -60,6 +59,7 @@ jobs:
|
||||
key: ${{ secrets.PROXMOX_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/webshop/supabase/supabase-project
|
||||
# 1. Altes Image durch das neue ersetzen
|
||||
docker compose pull webshop
|
||||
# 2. Container im Hintergrund neu starten
|
||||
docker compose up -d webshop
|
||||
docker image prune -f
|
||||
|
||||
249
AsciiShaderBackground.astro
Normal file
249
AsciiShaderBackground.astro
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
// AsciiShaderBackground.astro
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { className = "" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`ascii-shader-container ${className}`}>
|
||||
<canvas id="ascii-shader-canvas"></canvas>
|
||||
<div class="grid-overlay"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ascii-shader-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
background-color: oklch(0.145 0.01 148);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#ascii-shader-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// OKLCH Palette definition
|
||||
interface OKLCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// Palette defining color stops based on scroll progress
|
||||
const PALETTE = {
|
||||
yellow: { l: 0.88, c: 0.18, h: 96 },
|
||||
green: { l: 0.76, c: 0.15, h: 155 },
|
||||
blue: { l: 0.68, c: 0.18, h: 255 },
|
||||
purple: { l: 0.64, c: 0.18, h: 302 },
|
||||
pink: { l: 0.72, c: 0.20, h: 345 }
|
||||
};
|
||||
|
||||
// Helper for shortest angle interpolation in OKLCH hue space
|
||||
function interpolateHue(h1: number, h2: number, t: number): number {
|
||||
let diff = (h2 - h1) % 360;
|
||||
if (diff > 180) diff -= 360;
|
||||
if (diff < -180) diff += 360;
|
||||
return (h1 + diff * t + 360) % 360;
|
||||
}
|
||||
|
||||
// Interpolate two OKLCH colors
|
||||
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
||||
return {
|
||||
l: c1.l + (c2.l - c1.l) * t,
|
||||
c: c1.c + (c2.c - c1.c) * t,
|
||||
h: interpolateHue(c1.h, c2.h, t)
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate dynamic colors based on scroll progress (0.0 to 1.0)
|
||||
function getScrollColors(progress: number): { primary: OKLCHColor; secondary: OKLCHColor } {
|
||||
if (progress < 0.33) {
|
||||
// Hero to stage 1: Full spectrum (Pink -> Yellow)
|
||||
const t = progress / 0.33;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.pink, PALETTE.yellow, t),
|
||||
secondary: lerpOKLCH(PALETTE.purple, PALETTE.green, t)
|
||||
};
|
||||
} else if (progress < 0.66) {
|
||||
// Stage 1 to stage 2: Yellow/Cream to Green
|
||||
const t = (progress - 0.33) / 0.33;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.yellow, PALETTE.green, t),
|
||||
secondary: lerpOKLCH(PALETTE.green, PALETTE.blue, t)
|
||||
};
|
||||
} else {
|
||||
// Stage 2 to end: Green/Blue focus
|
||||
const t = (progress - 0.66) / 0.34;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.green, PALETTE.blue, t),
|
||||
secondary: lerpOKLCH(PALETTE.blue, PALETTE.purple, t)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AsciiShaderEngine {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
|
||||
private scrollProgress = 0;
|
||||
private targetScrollProgress = 0;
|
||||
private chars = ['/', '\\', '-', '|', '+', '='];
|
||||
private fontSize = 16;
|
||||
private cols = 0;
|
||||
private rows = 0;
|
||||
private animationFrameId = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not get 2D context');
|
||||
this.ctx = context;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.resize();
|
||||
this.bindEvents();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private resize = (): void => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
|
||||
this.ctx.scale(dpr, dpr);
|
||||
|
||||
this.cols = Math.ceil(width / this.fontSize);
|
||||
this.rows = Math.ceil(height / this.fontSize);
|
||||
|
||||
if (this.mouse.x === 0 && this.mouse.y === 0) {
|
||||
this.mouse.x = width / 2;
|
||||
this.mouse.y = height / 2;
|
||||
this.mouse.targetX = width / 2;
|
||||
this.mouse.targetY = height / 2;
|
||||
}
|
||||
};
|
||||
|
||||
private bindEvents(): void {
|
||||
window.addEventListener('resize', this.resize);
|
||||
|
||||
window.addEventListener('pointermove', (e: PointerEvent) => {
|
||||
this.mouse.targetX = e.clientX;
|
||||
this.mouse.targetY = e.clientY;
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const maxScroll = Math.max(
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
1
|
||||
);
|
||||
this.targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
private render = (time: number = 0): void => {
|
||||
// Dampened mouse easing (lerp factor 0.05)
|
||||
this.mouse.x += (this.mouse.targetX - this.mouse.x) * 0.05;
|
||||
this.mouse.y += (this.mouse.targetY - this.mouse.y) * 0.05;
|
||||
|
||||
// Dampened scroll progress easing
|
||||
this.scrollProgress += (this.targetScrollProgress - this.scrollProgress) * 0.05;
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get current OKLCH interpolated colors
|
||||
const { primary, secondary } = getScrollColors(this.scrollProgress);
|
||||
|
||||
this.ctx.font = `${this.fontSize}px monospace`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
|
||||
const timeSec = time * 0.001;
|
||||
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const x = c * this.fontSize + this.fontSize / 2;
|
||||
const y = r * this.fontSize + this.fontSize / 2;
|
||||
|
||||
// Vector field calculations based on distance to cursor & wave function
|
||||
const dx = x - this.mouse.x;
|
||||
const dy = y - this.mouse.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Dynamic angle field
|
||||
const angle = Math.atan2(dy, dx) + Math.sin(dist * 0.01 - timeSec) * 0.5;
|
||||
const charIndex = Math.floor(Math.abs(Math.sin(angle + dist * 0.005)) * this.chars.length) % this.chars.length;
|
||||
const char = this.chars[charIndex];
|
||||
|
||||
// Color blend based on vector field intensity
|
||||
const blendFactor = Math.min(Math.max(1 - dist / 400, 0), 1);
|
||||
const currentColor = lerpOKLCH(secondary, primary, blendFactor);
|
||||
|
||||
// Render character with OKLCH styling
|
||||
this.ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)})`;
|
||||
this.ctx.fillText(char, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
public destroy(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle handler for Astro / SPA transitions
|
||||
function initShader() {
|
||||
const canvas = document.getElementById('ascii-shader-canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
new AsciiShaderEngine(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initShader);
|
||||
} else {
|
||||
initShader();
|
||||
}
|
||||
|
||||
// Astro page swap support
|
||||
document.addEventListener('astro:page-load', initShader);
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { sendMail } from '@/utils/mail';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||
import { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -57,11 +58,120 @@ async function triggerPostProcessing(orderId: string, supabase: any, customerSna
|
||||
|
||||
// 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 emailTemplate = generateOrderEmailHtml({
|
||||
orderNumber: order.order_number,
|
||||
status: 'pending',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
items,
|
||||
taxRate,
|
||||
oneTimeNet,
|
||||
monthlyNet,
|
||||
});
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: `Bestätigung Ihrer CASPOS-Anfrage ${order.order_number}`,
|
||||
text: `Vielen Dank für Ihre Anfrage ${order.order_number}.`,
|
||||
html: `<p>Vielen Dank für Ihre Anfrage <strong>${order.order_number}</strong>.</p>`
|
||||
subject: mailSubject,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -73,8 +183,9 @@ export async function checkoutAction(params: {
|
||||
items: any[];
|
||||
endCustomerId: string;
|
||||
customerProfile?: any;
|
||||
lastLicenseDate?: string | null;
|
||||
}) {
|
||||
const { items, endCustomerId, customerProfile } = params;
|
||||
const { items, endCustomerId, customerProfile, lastLicenseDate } = params;
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. Session & Auth Check
|
||||
@@ -89,7 +200,7 @@ export async function checkoutAction(params: {
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.');
|
||||
}
|
||||
@@ -147,11 +258,13 @@ export async function checkoutAction(params: {
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
item.moduleQuantities,
|
||||
type === 'purchase' ? lastLicenseDate : null
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
device_name: item.deviceName,
|
||||
license_number: item.licenseNumber
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
@@ -164,7 +277,8 @@ export async function checkoutAction(params: {
|
||||
total: total,
|
||||
tax_rate: 19,
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100,
|
||||
last_license_date: type === 'purchase' ? lastLicenseDate : null
|
||||
};
|
||||
|
||||
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
|
||||
|
||||
@@ -9,7 +9,7 @@ export default async function AdminCategoriesPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<LayoutGrid className="w-8 h-8 text-primary" />
|
||||
Kategorien
|
||||
</h2>
|
||||
|
||||
@@ -245,32 +245,32 @@ export default function CompanyCustomersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||
<tr key={customer.id} className="hover:bg-slate-100 dark:hover:bg-white/10 transition-colors group">
|
||||
<td className="py-4">
|
||||
<div className="font-semibold text-slate-900 dark:text-white">
|
||||
{customer.company_name}
|
||||
</div>
|
||||
{(customer.first_name || customer.last_name) && (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<div className="text-xs text-slate-600 dark:text-slate-300 font-medium flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{customer.street ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<MapPin className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||
<span>
|
||||
{customer.street}, {customer.zip} {customer.city}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 dark:text-slate-600">–</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
{customer.email || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{customer.email || <span className="text-slate-400 dark:text-slate-500">–</span>}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||
|
||||
@@ -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">
|
||||
@@ -166,25 +197,25 @@ export default function CompaniesPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||
{filteredCompanies.map((company) => (
|
||||
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||
<tr key={company.id} className="hover:bg-slate-100 dark:hover:bg-white/10 transition-colors group">
|
||||
<td className="py-4 font-semibold text-slate-900 dark:text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
<Building2 className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
{company.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<span>
|
||||
{company.street ? `${company.street}, ${company.zip} ${company.city}` : '–'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{company.email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<Mail className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<span>{company.email}</span>
|
||||
</div>
|
||||
) : '–'}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
124
shop/app/admin/einstellungen/branding/page.tsx
Normal file
124
shop/app/admin/einstellungen/branding/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Palette, Save } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
|
||||
import type { BrandingSettings } from '@/lib/constants/branding';
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function BrandingPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [branding, setBranding] = useState<BrandingSettings>({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const { refreshBranding } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const brandRes = await getBrandingSettings();
|
||||
if (brandRes) setBranding(brandRes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await saveBrandingSettings(branding);
|
||||
if (res.success) {
|
||||
await refreshBranding();
|
||||
setMsgType('success');
|
||||
setMsg('Farbschema erfolgreich gespeichert.');
|
||||
} else {
|
||||
setMsgType('error');
|
||||
setMsg(res.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMsgType('error');
|
||||
setMsg(err.message || 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Palette className="w-8 h-8 text-violet-400" />
|
||||
Branding & Design
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Wählen Sie das primäre Farbschema und Akzente für den gesamten Webshop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Farben Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<ColorThemePicker
|
||||
colorScheme={branding.colorScheme}
|
||||
primaryColor={branding.primaryColor}
|
||||
accentColor={branding.accentColor}
|
||||
companyName={branding.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBranding({
|
||||
...branding,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
shop/app/admin/einstellungen/firmendaten/page.tsx
Normal file
231
shop/app/admin/einstellungen/firmendaten/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Building2, MapPin, Receipt, Save } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
|
||||
import type { BrandingSettings } from '@/lib/constants/branding';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function FirmendatenPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [branding, setBranding] = useState<BrandingSettings>({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const brandRes = await getBrandingSettings();
|
||||
if (brandRes) setBranding(brandRes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await saveBrandingSettings(branding);
|
||||
if (res.success) {
|
||||
setMsgType('success');
|
||||
setMsg('Firmendaten erfolgreich gespeichert.');
|
||||
} else {
|
||||
setMsgType('error');
|
||||
setMsg(res.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMsgType('error');
|
||||
setMsg(err.message || 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Building2 className="w-8 h-8 text-blue-400" />
|
||||
Firmendaten & Anschrift
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Verwaltung der eigenen Unternehmensanschrift für Rechnungen, Dokumente und Footereinträge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Firmenname *</Label>
|
||||
<Input
|
||||
value={branding.companyName}
|
||||
onChange={(e) => setBranding({ ...branding, companyName: e.target.value })}
|
||||
placeholder="z. B. Meine Firma GmbH"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Logo-URL (Optional)</Label>
|
||||
<Input
|
||||
value={branding.logoUrl || ''}
|
||||
onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })}
|
||||
placeholder="https://domain.de/logo.png oder /assets/logo.png"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Entwickler / Subline Beschriftung (Footer & Header)</Label>
|
||||
<Input
|
||||
value={branding.developerFooter || ''}
|
||||
onChange={(e) => setBranding({ ...branding, developerFooter: e.target.value })}
|
||||
placeholder="CASPOS Shop"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 pt-3 border-t border-slate-800">
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Hauptanschrift
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Straße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.street}
|
||||
onChange={(e) => setBranding({ ...branding, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.zip}
|
||||
onChange={(e) => setBranding({ ...branding, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.city}
|
||||
onChange={(e) => setBranding({ ...branding, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branding.sameBillingAddress}
|
||||
onChange={(e) => setBranding({ ...branding, sameBillingAddress: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-slate-700 bg-slate-950 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Hauptanschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!branding.sameBillingAddress ? (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-slate-950/40 border border-slate-800">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Rechnungsstraße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.billingStreet}
|
||||
onChange={(e) => setBranding({ ...branding, billingStreet: e.target.value })}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.billingZip}
|
||||
onChange={(e) => setBranding({ ...branding, billingZip: e.target.value })}
|
||||
placeholder="54321"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.billingCity}
|
||||
onChange={(e) => setBranding({ ...branding, billingCity: e.target.value })}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-xl bg-slate-950/40 border border-slate-800/60 text-xs text-slate-500 italic">
|
||||
Verwendet automatisch die Hauptanschrift oben.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-slate-800">
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-blue-600 hover:bg-blue-500 text-white rounded-xl px-6 h-10 text-xs font-bold">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
shop/app/admin/einstellungen/licserver/page.tsx
Normal file
162
shop/app/admin/einstellungen/licserver/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
|
||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LicServerPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle();
|
||||
if (data) {
|
||||
setLicUrl(data.licserver_base_url || '');
|
||||
setLicKey(data.licserver_api_key || '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<KeyRound className="w-8 h-8 text-violet-400" />
|
||||
CASPOS LicServer
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
REST API-Anbindung für die automatisierte Lizenzierung und Schlüsselgenerierung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{licStatus && (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
|
||||
licStatus.ok
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||||
}`}>
|
||||
{licStatus.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
{licStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{licMsg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
licMsgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{licMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 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 bg-slate-950/80 border-slate-800 text-white rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 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 bg-slate-950/80 border-slate-800 text-white rounded-xl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(v => !v)}
|
||||
className="absolute right-3 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>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-800">
|
||||
<Button
|
||||
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 rounded-xl px-5 h-10 text-xs font-bold"
|
||||
>
|
||||
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicTesting(true);
|
||||
setLicStatus(null);
|
||||
await saveLicServerConfig(licUrl, licKey);
|
||||
const result = await testLicServerConnection();
|
||||
setLicStatus(result);
|
||||
setLicTesting(false);
|
||||
}}
|
||||
className="border-slate-700 text-slate-300 hover:text-white rounded-xl px-5 h-10 text-xs font-bold"
|
||||
>
|
||||
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +1,126 @@
|
||||
/* Admin Settings with Database Import / Export functionality */
|
||||
'use client';
|
||||
|
||||
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 { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Building2, Palette, KeyRound, Mail, Sliders, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const [demoActive, setDemoActive] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Firmendaten & Anschrift',
|
||||
description: 'Verwaltung der eigenen Firmenanschrift, Rechnungsadresse und Sublines für Dokumente.',
|
||||
href: '/admin/einstellungen/firmendaten',
|
||||
icon: Building2,
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/20',
|
||||
},
|
||||
{
|
||||
title: 'Branding & Farbschema',
|
||||
description: 'Anpassung von Farbschemata (Primary / Accent), Buttons und Themes für den Webshop.',
|
||||
href: '/admin/einstellungen/branding',
|
||||
icon: Palette,
|
||||
color: 'text-violet-400',
|
||||
bgColor: 'bg-violet-500/10 border-violet-500/20',
|
||||
},
|
||||
{
|
||||
title: 'CASPOS LicServer',
|
||||
description: 'REST API Konfiguration für den Lizenzserver & automatisierte Schlüsselgenerierung.',
|
||||
href: '/admin/einstellungen/licserver',
|
||||
icon: KeyRound,
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-500/10 border-amber-500/20',
|
||||
},
|
||||
{
|
||||
title: 'SMTP E-Mail Server',
|
||||
description: 'Serverdaten & Authentifizierung für Bestellbestätigungen und Benachrichtigungen.',
|
||||
href: '/admin/settings',
|
||||
icon: Mail,
|
||||
color: 'text-sky-400',
|
||||
bgColor: 'bg-sky-500/10 border-sky-500/20',
|
||||
},
|
||||
{
|
||||
title: 'System & Backup',
|
||||
description: 'Demo-Banner Steuerung sowie ZIP-Export und Wiederherstellung der Datenbank.',
|
||||
href: '/admin/einstellungen/system',
|
||||
icon: Sliders,
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminSettingsOverview() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 {
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase.from('users').select('role').eq('id', user.id).single();
|
||||
if (!data || data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAccess();
|
||||
}, [router]);
|
||||
|
||||
const toggleDemo = (checked: boolean) => {
|
||||
setDemoActive(checked);
|
||||
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
setStatusMsg('Export läuft...');
|
||||
setStatusType('info');
|
||||
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');
|
||||
a.href = url;
|
||||
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
setStatusMsg('Export erfolgreich abgeschlossen.');
|
||||
setStatusType('success');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Export: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setStatusMsg('Import läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const res = await fetch('/api/admin/db-backup', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
||||
|
||||
setStatusMsg('Datenbank erfolgreich importiert!');
|
||||
setStatusType('success');
|
||||
setSelectedFile(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Import: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Verwalten Sie globale Shopeinstellungen.
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white">Systemeinstellungen</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Wählen Sie eine Kategorie aus, um Konfigurationen, Server-Anbindungen oder Branding anzupassen.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Allgemeine Einstellungen */}
|
||||
<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-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
Allgemeine Einstellungen
|
||||
</h2>
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
|
||||
</div>
|
||||
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datenimport & -export */}
|
||||
<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-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
Datenimport & -export
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Sichern Sie Ihre gesamte Instanz oder stellen Sie Daten wieder her.
|
||||
</p>
|
||||
</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' :
|
||||
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'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Export Card */}
|
||||
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Download className="w-4 h-4 text-primary" />
|
||||
Daten exportieren
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
{exporting ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
|
||||
) : (
|
||||
<><Download className="w-4 h-4 mr-2" /> ZIP-Backup herunterladen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import Card */}
|
||||
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Upload className="w-4 h-4 text-amber-500" />
|
||||
Daten importieren
|
||||
</h3>
|
||||
<div className="flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 p-2.5 rounded-lg text-amber-600 dark:text-amber-400 text-[11px] leading-snug">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Warnung:</strong> Der Import überschreibt alle Daten dieser Instanz unwiderruflich!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{selectedFile ? (
|
||||
<span className="text-primary font-semibold">{selectedFile.name}</span>
|
||||
) : (
|
||||
"ZIP-Datei auswählen oder hierher ziehen"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 dark:border-white/10"
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{menuItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={idx} href={item.href}>
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md h-full flex flex-col justify-between space-y-4 hover:border-slate-700 transition-all shadow-md group cursor-pointer"
|
||||
>
|
||||
{importing ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Import läuft...</>
|
||||
) : (
|
||||
<><Upload className="w-4 h-4 mr-2" /> ZIP-Backup einspielen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className={`w-12 h-12 rounded-xl border flex items-center justify-center ${item.bgColor}`}>
|
||||
<Icon className={`w-6 h-6 ${item.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs font-bold text-slate-300 group-hover:text-primary pt-3 border-t border-slate-800/80 gap-1.5 transition-colors">
|
||||
<span>Einstellungen öffnen</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
226
shop/app/admin/einstellungen/system/page.tsx
Normal file
226
shop/app/admin/einstellungen/system/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Download, Upload, Loader2, Sliders, ShieldAlert, FileArchive } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function SystemPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [demoActive, setDemoActive] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const toggleDemo = (checked: boolean) => {
|
||||
setDemoActive(checked);
|
||||
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
setStatusMsg('Export läuft...');
|
||||
setStatusType('info');
|
||||
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');
|
||||
a.href = url;
|
||||
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
setStatusMsg('Export erfolgreich abgeschlossen.');
|
||||
setStatusType('success');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Export: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setStatusMsg('Import läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const res = await fetch('/api/admin/db-backup', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
||||
|
||||
setStatusMsg('Datenbank erfolgreich importiert!');
|
||||
setStatusType('success');
|
||||
setSelectedFile(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Import: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Sliders className="w-8 h-8 text-amber-400" />
|
||||
System & Backup
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Verwaltung von System-Bannern, Datenbank-Sicherungen und Wiederherstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
statusType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' :
|
||||
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||
'bg-sky-500/10 border-sky-500/20 text-sky-400'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/20 text-sky-400 flex items-center justify-center">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">System Banner</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Gelbe Demo-Warnmeldungen im gesamten Shop aktivieren oder stummschalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-800/80">
|
||||
<span className="text-xs font-semibold text-slate-300">Banner aktiv</span>
|
||||
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||
<Download className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">DB Backup</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold"
|
||||
>
|
||||
{exporting ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
|
||||
) : (
|
||||
<><Download className="w-4 h-4 mr-2" /> ZIP Export</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 p-5 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-400 bg-amber-500/10 px-2.5 py-1 rounded-full border border-amber-500/20 flex items-center gap-1">
|
||||
<ShieldAlert className="w-3 h-3" /> Überschreibt DB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Daten Wiederherstellung</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Spielen Sie eine gesicherte ZIP-Sicherungsdatei ein. Warnung: Überschreibt aktuelle Datenbankinhalte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 pt-2">
|
||||
<div className="relative border border-dashed border-slate-700/80 rounded-xl p-3 hover:bg-slate-800/50 transition cursor-pointer flex items-center justify-center text-center">
|
||||
<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"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-300 truncate px-2 flex items-center gap-1.5">
|
||||
<FileArchive className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
{selectedFile ? selectedFile.name : 'ZIP-Datei auswählen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="outline"
|
||||
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 rounded-xl h-10 text-xs font-bold"
|
||||
>
|
||||
{importing ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Importiert...</>
|
||||
) : (
|
||||
<><Upload className="w-4 h-4 mr-2" /> ZIP Einspielen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,17 +77,25 @@ export default async function AdminLayout({
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
|
||||
</div>
|
||||
<AdminNavLink href="/admin/einstellungen">
|
||||
<Settings className="w-5 h-5" />
|
||||
Allgemein
|
||||
<AdminNavLink href="/admin/einstellungen/firmendaten">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Firmendaten
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/einstellungen/branding">
|
||||
<Sparkles className="w-5 h-5 text-violet-400" />
|
||||
Branding & Design
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/einstellungen/licserver">
|
||||
<Wrench className="w-5 h-5 text-amber-400" />
|
||||
LicServer
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/settings">
|
||||
<Wrench className="w-5 h-5" />
|
||||
SMTP
|
||||
<Settings className="w-5 h-5 text-sky-400" />
|
||||
SMTP Server
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/tools">
|
||||
<Database className="w-5 h-5" />
|
||||
DB-Tools
|
||||
<AdminNavLink href="/admin/einstellungen/system">
|
||||
<Database className="w-5 h-5 text-emerald-400" />
|
||||
System & Backup
|
||||
</AdminNavLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,8 +7,8 @@ export default async function AdminOrdersPage() {
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen</h1>
|
||||
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Annfragesbestätigungen zu.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white">Anfragen & Bestellungen</h1>
|
||||
<p className="text-slate-400">Verwalten Sie alle Anfragen, prüfen Sie Details und greifen Sie auf generierte Anfragebestätigungen zu.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
||||
import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
|
||||
async function getKpis() {
|
||||
@@ -40,55 +41,63 @@ export default async function AdminDashboard() {
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white">Dashboard</h2>
|
||||
|
||||
{/* KPI Cards – echte DB-Daten */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||
<p className="text-xs text-slate-400">Gesamt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||
<p className="text-xs text-slate-400">Gesamt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/products" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||
<p className="text-xs text-slate-400">Registrierte User</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/users" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||
<p className="text-xs text-slate-400">Registrierte User</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Letzte Bestellungen – Client Component mit 30s-Polling */}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function AdminProductsPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-primary" />
|
||||
Produkte & Module
|
||||
</h2>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Mail, Server, ShieldCheck, Key, Send, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface Settings {
|
||||
host: string;
|
||||
@@ -13,39 +17,60 @@ interface Settings {
|
||||
pass: string;
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 15 },
|
||||
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Load current settings and verify access
|
||||
useEffect(() => {
|
||||
async function checkAccessAndLoad() {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/admin/smtp-settings')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.settings) setSettings(data.settings as Settings);
|
||||
else setMessage('Failed to load settings');
|
||||
})
|
||||
.catch(() => setMessage('Failed to load settings'))
|
||||
.finally(() => setLoading(false));
|
||||
const [userRes, smtpRes] = await Promise.all([
|
||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
||||
fetch('/api/admin/smtp-settings').then(res => res.json())
|
||||
]);
|
||||
|
||||
if (!userRes.data || userRes.data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (smtpRes.settings) {
|
||||
setSettings(smtpRes.settings as Settings);
|
||||
} else {
|
||||
setMessage('Fehler beim Laden der SMTP-Einstellungen.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage('Fehler beim Laden der Einstellungen.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAccessAndLoad();
|
||||
}, [router]);
|
||||
@@ -62,110 +87,195 @@ export default function SettingsPage() {
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
const res = await fetch('/api/admin/smtp-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) setMessage('Settings saved');
|
||||
else setMessage(data.error || 'Error saving settings');
|
||||
try {
|
||||
const res = await fetch('/api/admin/smtp-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage('SMTP-Einstellungen erfolgreich gespeichert.');
|
||||
setMsgType('success');
|
||||
} else {
|
||||
setMessage(data.error || 'Fehler beim Speichern.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch {
|
||||
setMessage('Verbindungsfehler beim Speichern.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
setTesting(true);
|
||||
setMessage('');
|
||||
const testPayload = {
|
||||
to: settings?.user || '', // send to the configured user address
|
||||
subject: 'Test Email from Webshop Admin',
|
||||
text: 'This is a test email to verify SMTP configuration.',
|
||||
};
|
||||
const res = await fetch('/api/admin/send-test-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) setMessage('Test email sent successfully');
|
||||
else setMessage(data.error || 'Failed to send test email');
|
||||
try {
|
||||
const testPayload = {
|
||||
to: settings?.user || '',
|
||||
subject: 'Test-E-Mail aus dem CASPOS Webshop Admin',
|
||||
text: 'Dies ist eine automatische Test-E-Mail zur Bestätigung der SMTP-Konfiguration.',
|
||||
};
|
||||
const res = await fetch('/api/admin/send-test-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage('Test-E-Mail erfolgreich versendet.');
|
||||
setMsgType('success');
|
||||
} else {
|
||||
setMessage(data.error || 'Versand der Test-E-Mail fehlgeschlagen.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch {
|
||||
setMessage('Fehler beim Senden der Test-E-Mail.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-8 bg-black/30 backdrop-blur-xl rounded-lg text-white">
|
||||
<h1 className="text-2xl font-bold mb-6">SMTP‑Einstellungen</h1>
|
||||
{message && <p className="mb-4 text-yellow-300">{message}</p>}
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<label className="flex flex-col">
|
||||
Host
|
||||
<input
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
value={settings?.host ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Port
|
||||
<input
|
||||
name="port"
|
||||
type="number"
|
||||
required
|
||||
value={settings?.port ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center space-x-2">
|
||||
<input
|
||||
name="secure"
|
||||
type="checkbox"
|
||||
checked={settings?.secure ?? false}
|
||||
onChange={handleChange}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>SSL / TLS (secure)</span>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Benutzer (SMTP‑User)
|
||||
<input
|
||||
name="user"
|
||||
type="email"
|
||||
required
|
||||
value={settings?.user ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Passwort
|
||||
<input
|
||||
name="pass"
|
||||
type="password"
|
||||
required
|
||||
value={settings?.pass ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex space-x-4 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary rounded hover:bg-primary/80 transition"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestEmail}
|
||||
className="px-4 py-2 bg-green-600 rounded hover:bg-green-500 transition"
|
||||
>
|
||||
Test‑Mail senden
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Mail className="w-8 h-8 text-sky-400" />
|
||||
SMTP-Einstellungen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Konfigurieren Sie den E-Mail-Server für transaktionale Benachrichtigungen und Bestellbestätigungen.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Dynamic Status Message */}
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className={`p-3.5 rounded-xl text-xs font-semibold border flex items-center gap-2 ${
|
||||
msgType === 'success'
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{msgType === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
|
||||
{message}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Bento Grid layout for SMTP */}
|
||||
<form onSubmit={handleSave}>
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Bento Box 1: Server Connection Details (2 Spalten) */}
|
||||
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
|
||||
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>Server & Serververbindung</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-2 space-y-1.5">
|
||||
<Label htmlFor="host" className="text-xs font-bold text-slate-300">SMTP Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
value={settings?.host ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="smtp.beispiel.de"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="port" className="text-xs font-bold text-slate-300">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
name="port"
|
||||
type="number"
|
||||
required
|
||||
value={settings?.port ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="587"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer p-3 rounded-xl bg-slate-950/50 border border-slate-800/80 hover:bg-slate-800/40 transition">
|
||||
<input
|
||||
name="secure"
|
||||
type="checkbox"
|
||||
checked={settings?.secure ?? false}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded text-sky-500 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-slate-200">SSL / TLS Verschlüsselung aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Box 2: Auth Credentials (1 Spalte) */}
|
||||
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Zugangsdaten</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user" className="text-xs font-bold text-slate-300">SMTP Benutzer</Label>
|
||||
<Input
|
||||
id="user"
|
||||
name="user"
|
||||
type="email"
|
||||
required
|
||||
value={settings?.user ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="absender@beispiel.de"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pass" className="text-xs font-bold text-slate-300">Passwort</Label>
|
||||
<Input
|
||||
id="pass"
|
||||
name="pass"
|
||||
type="password"
|
||||
required
|
||||
value={settings?.pass ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="••••••••••••"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit & Test Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-4 border-t border-slate-800">
|
||||
<Button type="submit" disabled={saving || testing} className="w-full bg-sky-600 hover:bg-sky-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Speichert...</> : 'Konfiguration speichern'}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleTestEmail} disabled={saving || testing} variant="outline" className="w-full border-slate-700 text-slate-300 hover:text-white rounded-xl h-10 text-xs font-bold">
|
||||
{testing ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Sendet...</> : <><Send className="w-3.5 h-3.5 mr-2 text-emerald-400" /> Test-Mail senden</>}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getDatabaseIntegrity,
|
||||
repairDatabaseSchema,
|
||||
optimizeDatabaseIndices
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
getDatabaseIntegrity,
|
||||
repairDatabaseSchema,
|
||||
optimizeDatabaseIndices
|
||||
} from "@/lib/actions/admin";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, RefreshCw, Wrench, Database, CheckCircle2, XCircle, Terminal } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -26,6 +25,19 @@ interface IntegrityData {
|
||||
};
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 15 },
|
||||
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
|
||||
};
|
||||
|
||||
export default function AdminToolsPage() {
|
||||
const [integrity, setIntegrity] = useState<IntegrityData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -88,218 +100,145 @@ export default function AdminToolsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAccess() {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const [userRes] = await Promise.all([
|
||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
||||
loadStatus(true)
|
||||
]);
|
||||
|
||||
if (!userRes.data || userRes.data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Laden von Admin Tools:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
if (!userData || userData.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
loadStatus();
|
||||
}
|
||||
checkAccess();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen bg-[#020617] text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
<p className="text-slate-200 text-lg font-medium">
|
||||
{activeAction === "repair"
|
||||
? "Schema wird repariert..."
|
||||
: activeAction === "optimize"
|
||||
? "Datenbank wird indexiert..."
|
||||
: "Verbindung zur Datenbank wird aufgebaut..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 max-w-7xl mx-auto text-slate-900 dark:text-white space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Database className="w-8 h-8 text-primary" />
|
||||
Datenbank Cockpit & Tools
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Database className="w-8 h-8 text-primary" />
|
||||
Admin Datenbank-Cockpit
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-blue-400" />
|
||||
Integritätsscan
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{/* Bento Grid layout */}
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Bento Card 1: Integritätsscan */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-blue-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Integritätsscan</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Prüft die physische Existenz der Tabellen und zählt deren Zeilenanzahl.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={() => loadStatus()}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Status aktualisieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-emerald-400" />
|
||||
Schema reparieren
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert die RLS-Rechte.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={handleRepair}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
Schema reparieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-amber-400" />
|
||||
Index optimieren
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Baut korrupte Suchindizes im Schema public mittels REINDEX neu auf.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={handleOptimize}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
|
||||
>
|
||||
Index optimieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabellenstatus */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-xl">Tabellen-Status & Diagnose</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{integrity?.tables ? (
|
||||
Object.entries(integrity.tables).map(([name, status]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="p-4 rounded-xl border bg-white/5 border-white/10 flex flex-col justify-between gap-2"
|
||||
>
|
||||
<span className="font-semibold text-slate-300 text-sm capitalize">
|
||||
{name.replace("_", " ")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Zeilen: {status.count}</span>
|
||||
{status.exists ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
|
||||
<XCircle className="w-3.5 h-3.5" /> Fehlt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-4 py-8 text-center text-slate-500 text-sm">
|
||||
Lade Diagnosedaten...
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => loadStatus()} disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === null ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüft...</> : 'Status Scannen'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Foreign Key Errors */}
|
||||
{integrity && integrity.errors.foreign_keys > 0 && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 items-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-red-200">Fremdschlüssel-Fehler gefunden!</p>
|
||||
<p className="text-red-300/80 text-sm">
|
||||
Es wurden {integrity.errors.foreign_keys} verwaiste Lizenzen ohne zugeordneten Endkunden in der Tabelle `licenses` detektiert.
|
||||
</p>
|
||||
{/* Bento Card 2: Schema Reparieren */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-emerald-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<Wrench className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Schema Reparieren</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert RLS-Rechte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleRepair} disabled={isLoading} className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === 'repair' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Repariert...</> : 'Schema Reparieren'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Card 3: Index Optimieren */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-amber-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Index Optimieren</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Baut Suchindizes im Schema public mittels REINDEX neu auf für maximale Performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleOptimize} disabled={isLoading} className="w-full bg-amber-600 hover:bg-amber-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === 'optimize' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Indexiert...</> : 'Index Optimieren'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Card 4: Tabellen-Diagnose (2 Spalten) */}
|
||||
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
|
||||
<h3 className="font-bold text-base text-white border-b border-slate-800 pb-3">Tabellen-Status & Diagnose</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{integrity?.tables ? (
|
||||
Object.entries(integrity.tables).map(([name, status]) => (
|
||||
<div key={name} className="p-3 rounded-xl border bg-slate-950/60 border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-bold text-xs text-slate-200 capitalize block">{name.replace("_", " ")}</span>
|
||||
<span className="text-[10px] text-slate-400">Zeilen: {status.count}</span>
|
||||
</div>
|
||||
{status.exists ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[10px] py-0 px-1.5">OK</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-[10px] py-0 px-1.5">Fehlt</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 col-span-3">Keine Diagnose-Daten geladen.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Console / Output logs */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
Diagnose-Konsole
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLogs([])}
|
||||
className="text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
Konsole leeren
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="font-mono text-xs bg-black/50 p-4 rounded-xl border border-white/5 h-64 overflow-y-auto space-y-1.5 scrollbar-thin">
|
||||
{/* Bento Card 5: Live Konsole / Logs (1 Spalte) */}
|
||||
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-3 shadow-md flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-base text-white flex items-center gap-2 border-b border-slate-800 pb-3">
|
||||
<Terminal className="w-4 h-4 text-sky-400" /> System-Konsole
|
||||
</h3>
|
||||
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-[11px] text-slate-300 h-44 overflow-y-auto space-y-1 subpixel-antialiased">
|
||||
{logs.length === 0 ? (
|
||||
<span className="text-slate-600 italic">Konsole bereit. Führen Sie einen Scan aus.</span>
|
||||
<span className="text-slate-600 italic">Warte auf Aktionen...</span>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={log.includes("FEHLER") ? "text-red-400" : log.includes("Erfolgreich") ? "text-emerald-400" : "text-slate-300"}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
logs.map((log, i) => <div key={i} className="leading-tight">{log}</div>)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function AdminUsersPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-primary" />
|
||||
Benutzerverwaltung
|
||||
</h2>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function WysiwygAdminPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Edit className="w-8 h-8 text-primary" />
|
||||
WYSIWYG Live-Editor
|
||||
</h2>
|
||||
|
||||
@@ -402,6 +402,30 @@ export function WysiwygAdminClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfte monatliche Gebühr */}
|
||||
<div className="space-y-1 mt-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Verknüpfte Gebühr:</span>
|
||||
<select
|
||||
value={prod.linked_fee_product_id || ''}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, linked_fee_product_id: val } : p))
|
||||
await updateProduct(prod.id, { linked_fee_product_id: val }, prod.modules || [])
|
||||
}}
|
||||
className="w-full bg-slate-950 border border-white/10 text-[10px] text-slate-300 focus:ring-0 cursor-pointer rounded px-2 py-1 outline-none"
|
||||
>
|
||||
<option value="">Keine Gebühr verknüpft</option>
|
||||
{products
|
||||
.filter(p => p.billing_interval === 'monthly' && p.id !== prod.id)
|
||||
.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.base_price)} / mtl.)
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Modul hinzufügen Popover */}
|
||||
<div className="mt-3 pt-2 border-t border-white/5">
|
||||
<ModulePopover
|
||||
|
||||
@@ -67,7 +67,8 @@ export async function GET() {
|
||||
'orders',
|
||||
'profiles',
|
||||
'users',
|
||||
'settings'
|
||||
'settings',
|
||||
'branding_settings'
|
||||
];
|
||||
|
||||
const backupData: Record<string, any[]> = {};
|
||||
|
||||
@@ -82,11 +82,9 @@ export async function GET(
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
|
||||
dateStr: formattedDate,
|
||||
order: order,
|
||||
orderSnapshot: order.order_data,
|
||||
customer: order.customer_data,
|
||||
orderData: order.order_data,
|
||||
totalPrice: Number(order.total_price),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -43,7 +43,20 @@ export async function POST(request: Request) {
|
||||
|
||||
const { to, subject, text, html } = await request.json();
|
||||
try {
|
||||
const info = await sendMail({ to, subject, text, html });
|
||||
const { data: brandData } = await supabase.from('settings').select('primary_color').eq('id', 'branding').maybeSingle();
|
||||
const primaryColor = brandData?.primary_color || '#2563eb';
|
||||
|
||||
const testHtml = html || `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<h2 style="color: ${primaryColor}; margin-bottom: 16px;">Test-E-Mail</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${text || 'Dies ist eine automatische Test-E-Mail.'}</p>
|
||||
<div style="margin-top: 24px; padding: 12px; background-color: #f8fafc; border-left: 4px solid ${primaryColor}; font-size: 14px; color: #334155;">
|
||||
SMTP-Konfiguration & Branding-Farbe erfolgreich verifiziert.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await sendMail({ to, subject, text, html: testHtml });
|
||||
return NextResponse.json({ message: 'Email sent', info });
|
||||
} catch (err) {
|
||||
console.error('Mail error', err);
|
||||
|
||||
@@ -1,96 +1,76 @@
|
||||
// API route for getting and updating SMTP settings (protected by admin check)
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyAdmin } from '@/lib/actions/auth';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
await verifyAdmin();
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabase = createServerClient(
|
||||
supabaseUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
|
||||
}
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
try {
|
||||
let client: any;
|
||||
try {
|
||||
client = createAdminClient();
|
||||
} catch {
|
||||
client = await createClient();
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !authUser) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
const { data, error } = await client
|
||||
.from('settings')
|
||||
.select('*')
|
||||
.eq('id', 'smtp')
|
||||
.maybeSingle();
|
||||
|
||||
const { data, error } = await supabase.from('settings').select('*').maybeSingle();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
if (error) {
|
||||
console.error('Error fetching SMTP settings:', error);
|
||||
return NextResponse.json({
|
||||
settings: { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('SMTP settings GET error:', err);
|
||||
return NextResponse.json({
|
||||
settings: { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabase = createServerClient(
|
||||
supabaseUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
|
||||
}
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !authUser) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
try {
|
||||
await verifyAdmin();
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json(); // expect {host, port, secure, user, pass}
|
||||
const { error } = await supabase
|
||||
.from('settings')
|
||||
.upsert({ id: 'smtp', ...payload });
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
try {
|
||||
let client: any;
|
||||
try {
|
||||
client = createAdminClient();
|
||||
} catch {
|
||||
client = await createClient();
|
||||
}
|
||||
|
||||
const payload = await request.json();
|
||||
const { error } = await client
|
||||
.from('settings')
|
||||
.upsert({ id: 'smtp', ...payload });
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving SMTP settings:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'SMTP settings saved' });
|
||||
} catch (err: any) {
|
||||
console.error('SMTP settings POST error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Fehler beim Speichern' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ message: 'SMTP settings saved' });
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,11 +82,9 @@ export async function GET(
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
|
||||
dateStr: formattedDate,
|
||||
order: order,
|
||||
orderSnapshot: order.order_data,
|
||||
customer: order.customer_data,
|
||||
orderData: order.order_data,
|
||||
totalPrice: Number(order.total_price),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
|
||||
import { validateWizardSelections } from '@/lib/actions/validation';
|
||||
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -33,13 +36,13 @@ export async function POST(request: Request) {
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { items, customerProfile, endCustomerId, endCustomer } = body;
|
||||
const { items, customerProfile, endCustomerId, endCustomer, lastLicenseDate } = body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
|
||||
@@ -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'
|
||||
@@ -67,6 +77,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const orderItemsList: any[] = [];
|
||||
let total = 0;
|
||||
const feeProductIds = new Set<string>();
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
@@ -74,12 +85,63 @@ export async function POST(request: Request) {
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
item.moduleQuantities,
|
||||
type === 'purchase' ? lastLicenseDate : null
|
||||
);
|
||||
orderItemsList.push(...itemSnapshot.items);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName,
|
||||
license_number: item.licenseNumber
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
|
||||
// Echte linked fee products ermitteln
|
||||
if (item.selections) {
|
||||
for (const catId in item.selections) {
|
||||
const sel = item.selections[catId];
|
||||
|
||||
if (sel.productId) {
|
||||
const p = products.find((prod: any) => prod.id === sel.productId);
|
||||
if (p && p.linked_fee_product_id) {
|
||||
feeProductIds.add(p.linked_fee_product_id);
|
||||
}
|
||||
}
|
||||
|
||||
sel.productIds?.forEach((pId: string) => {
|
||||
const p = products.find((prod: any) => prod.id === pId);
|
||||
if (p && p.linked_fee_product_id) {
|
||||
feeProductIds.add(p.linked_fee_product_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feeProductIds.forEach(feeProdId => {
|
||||
const feeProduct = products.find((p: any) => p.id === feeProdId);
|
||||
if (feeProduct) {
|
||||
const isIntervalMatch =
|
||||
(type === 'purchase' && feeProduct.billing_interval === 'one_time') ||
|
||||
(type === 'subscription' && feeProduct.billing_interval === 'monthly');
|
||||
|
||||
if (isIntervalMatch) {
|
||||
orderItemsList.push({
|
||||
category_id: 'dienste-gebuehren',
|
||||
category_name: 'Dienste & Gebühren',
|
||||
product_id: feeProduct.id,
|
||||
product_name: feeProduct.name,
|
||||
base_price: Number(feeProduct.base_price),
|
||||
billing_interval: feeProduct.billing_interval,
|
||||
selected_modules: [],
|
||||
item_total: Number(feeProduct.base_price),
|
||||
device_name: 'Zusatzleistung'
|
||||
});
|
||||
total += Number(feeProduct.base_price);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
@@ -87,7 +149,8 @@ export async function POST(request: Request) {
|
||||
total: total,
|
||||
tax_rate: 19, // default
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100,
|
||||
last_license_date: type === 'purchase' ? lastLicenseDate : null
|
||||
};
|
||||
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot);
|
||||
@@ -113,6 +176,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 +188,6 @@ export async function POST(request: Request) {
|
||||
})
|
||||
);
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
@@ -140,11 +204,129 @@ 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 emailTemplate = generateOrderEmailHtml({
|
||||
orderNumber: order.order_number,
|
||||
status: 'pending',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
items,
|
||||
taxRate,
|
||||
oneTimeNet,
|
||||
monthlyNet,
|
||||
});
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
|
||||
|
||||
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: `Bestätigung Ihrer CASPOS-Anfrage ${order.order_number}`,
|
||||
text: `Vielen Dank für Ihre Anfrage ${order.order_number}.`,
|
||||
html: `<p>Vielen Dank für Ihre Anfrage <strong>${order.order_number}</strong>.</p>`
|
||||
subject: mailSubject,
|
||||
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>
|
||||
|
||||
@@ -2,110 +2,221 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light Mode: Maximierter Kontrast (Reines Weißer Hintergrund + Tiefschwarzer Text) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71% 4%;
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Primary: Cyan/Blau lesbarer gemacht */
|
||||
--primary: 199 100% 35%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
:root {
|
||||
/* OKLCH Zentral-Farbsystem */
|
||||
--oklch-background: 0.145 0.01 148;
|
||||
--oklch-surface: 0.20 0.01 148;
|
||||
--oklch-surface-hover: 0.25 0.012 148;
|
||||
--oklch-primary: 0.68 0.18 255;
|
||||
--oklch-primary-hover: 0.75 0.18 255;
|
||||
--oklch-secondary: 0.76 0.15 155;
|
||||
--oklch-text-main: 0.98 0 0;
|
||||
--oklch-text-muted: 0.70 0.01 148;
|
||||
--oklch-border: 0.31 0.012 148;
|
||||
|
||||
/* Muted & Accent: Deutlich dunklerer Text für Barrierefreiheit */
|
||||
--secondary: 220 14% 90%;
|
||||
--secondary-foreground: 224 71% 4%;
|
||||
--muted: 220 14% 92%;
|
||||
--muted-foreground: 220 20% 28%;
|
||||
/* Vorher 40% Helligkeit, jetzt 28% (viel dunkler) */
|
||||
--accent: 220 14% 90%;
|
||||
--accent-foreground: 224 71% 4%;
|
||||
/* Dynamische Branding Variablen */
|
||||
--primary-custom: #2563eb;
|
||||
--accent-custom: #38bdf8;
|
||||
--success-custom: #10b981;
|
||||
--warning-custom: #f59e0b;
|
||||
--destructive-custom: #ef4444;
|
||||
--bg-glow-1: #3b82f6;
|
||||
--bg-glow-2: #1d4ed8;
|
||||
--gradient-from: #2563eb;
|
||||
--gradient-to: #1e40af;
|
||||
|
||||
--destructive: 0 84% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 14% 75%;
|
||||
/* Grenzen sichtbarer gemacht */
|
||||
--input: 220 14% 75%;
|
||||
--ring: 199 100% 35%;
|
||||
--radius: 0.75rem;
|
||||
/* Classic HSL Mappings */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* 100% Dynamische Farbbindung für das gesamte Webshop Design-System */
|
||||
.bg-blue-600, .bg-blue-500, .bg-violet-600, .bg-purple-600, .bg-sky-500, .bg-cyan-600, .bg-indigo-600 {
|
||||
background-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-500:hover, .hover\:bg-violet-500:hover, .hover\:bg-blue-600:hover {
|
||||
background-color: var(--primary-custom) !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.text-blue-500, .text-blue-400, .text-blue-600, .text-violet-400, .text-sky-400, .text-sky-300, .text-indigo-400 {
|
||||
color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.border-blue-500, .border-blue-600, .border-violet-500, .border-sky-500 {
|
||||
border-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Transparenz-Schichten & Badges mit color-mix */
|
||||
[class*="bg-blue-500/"], [class*="bg-violet-500/"], [class*="bg-purple-500/"], [class*="bg-sky-500/"] {
|
||||
background-color: color-mix(in srgb, var(--primary-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-blue-500/"], [class*="border-violet-500/"], [class*="border-purple-500/"], [class*="border-sky-500/"] {
|
||||
border-color: color-mix(in srgb, var(--primary-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Gradients */
|
||||
.from-blue-600, .from-blue-500, .from-violet-600 {
|
||||
--tw-gradient-from: var(--gradient-from, var(--primary-custom)) !important;
|
||||
--tw-gradient-to: rgb(255 255 255 / 0) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
.to-indigo-600, .to-indigo-500, .to-sky-500, .to-sky-400, .to-violet-500 {
|
||||
--tw-gradient-to: var(--gradient-to, var(--accent-custom)) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Signalfarben (Success, Warning, Destructive) */
|
||||
.bg-emerald-500, .bg-green-500, .bg-emerald-600, .bg-green-600 {
|
||||
background-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.text-emerald-400, .text-green-400, .text-emerald-500, .text-green-500, .text-green-600 {
|
||||
color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.border-emerald-500, .border-green-500 {
|
||||
border-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-emerald-500/"], [class*="bg-green-500/"] {
|
||||
background-color: color-mix(in srgb, var(--success-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-emerald-500/"], [class*="border-green-500/"] {
|
||||
border-color: color-mix(in srgb, var(--success-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-amber-500, .bg-amber-600 {
|
||||
background-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.text-amber-400, .text-amber-500, .text-amber-600 {
|
||||
color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.border-amber-500, .border-amber-600 {
|
||||
border-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-amber-500/"] {
|
||||
background-color: color-mix(in srgb, var(--warning-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-amber-500/"] {
|
||||
border-color: color-mix(in srgb, var(--warning-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-red-500, .bg-red-600, .bg-rose-500, .bg-rose-600 {
|
||||
background-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.text-red-500, .text-red-400, .text-rose-400, .text-rose-500 {
|
||||
color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.border-red-500, .border-rose-500 {
|
||||
border-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-red-500/"], [class*="bg-rose-500/"] {
|
||||
background-color: color-mix(in srgb, var(--destructive-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-red-500/"], [class*="border-rose-500/"] {
|
||||
border-color: color-mix(in srgb, var(--destructive-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Background Glowing Orbs & Dynamic Surfaces */
|
||||
.blur-\[120px\] {
|
||||
background-color: var(--bg-glow-1) !important;
|
||||
}
|
||||
|
||||
.bg-\[\#020617\] {
|
||||
background-color: oklch(var(--oklch-background)) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Buttons & Badges über globale CSS Variablen */
|
||||
.btn-primary-theme, .bg-primary-theme {
|
||||
background-color: var(--button-bg, var(--primary-custom)) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.text-highlight-theme {
|
||||
color: var(--text-highlight, var(--accent-custom)) !important;
|
||||
}
|
||||
|
||||
.border-glow-theme {
|
||||
border-color: var(--card-border-glow, color-mix(in srgb, var(--accent-custom) 35%, transparent)) !important;
|
||||
}
|
||||
|
||||
.ring-theme {
|
||||
--tw-ring-color: var(--ring-color, var(--primary-custom)) !important;
|
||||
}
|
||||
|
||||
input, select, textarea, option {
|
||||
background-color: oklch(var(--oklch-surface)) !important;
|
||||
color: oklch(var(--oklch-text-main)) !important;
|
||||
border: 1px solid oklch(var(--oklch-border) / 0.4) !important;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--primary-custom) !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
option {
|
||||
color: oklch(var(--oklch-text-main)) !important;
|
||||
background-color: oklch(var(--oklch-surface)) !important;
|
||||
}
|
||||
|
||||
/* Eye-Catcher & Rüttel-Animation für fehlende Pflichtkategorien */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15%, 55% { transform: translateX(-4px); }
|
||||
35%, 75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@keyframes eye-catcher-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px rgba(244, 63, 94, 0.6), inset 0 0 8px rgba(244, 63, 94, 0.3);
|
||||
border-color: rgba(244, 63, 94, 0.9);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Mode: Reines Tiefschwarz erhöht den Kontrast zu weißem Text drastisch */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
/* Muted Text: Helligkeit massiv erhöht, damit Beschreibungen lesbar sind */
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 75%;
|
||||
/* Vorher 56.9% Helligkeit, jetzt 75% (viel heller) */
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
/* Helleres Rot für dunklen Hintergrund */
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 25%;
|
||||
/* Grenzen im Dunkeln besser sichtbar */
|
||||
--input: 240 3.7% 25%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
50% {
|
||||
box-shadow: 0 0 28px rgba(239, 68, 68, 0.9), inset 0 0 14px rgba(239, 68, 68, 0.5);
|
||||
border-color: rgba(239, 68, 68, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Automatischer High-Contrast-Modus für Betriebssystem-Vorgaben */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--foreground: 0 0% 0%;
|
||||
--muted-foreground: 0 0% 0%;
|
||||
--border: 0 0% 0%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 100%;
|
||||
--muted-foreground: 0 0% 100%;
|
||||
--border: 0 0% 100%;
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Glassmorphism Kontraste geschärft (höhere Deckkraft) */
|
||||
.glass {
|
||||
@apply bg-white/20 backdrop-blur-md border border-white/40;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-black/40 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.animate-eye-catcher {
|
||||
animation: eye-catcher-glow 1.2s ease-in-out infinite, shake 0.8s ease-in-out infinite !important;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
@@ -104,6 +104,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
Kunden-ID: <span className="font-mono">{customer.id.slice(0, 8)}…</span>
|
||||
</p>
|
||||
</div>
|
||||
{!customer.is_anonymized && (
|
||||
<Link href={`/order?customer_id=${customer.id}`} className="ml-auto">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_15px_rgba(59,130,246,0.3)] text-xs">
|
||||
<ShoppingBag className="w-4 h-4" /> Kunde updaten / bestellen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{customer.is_anonymized && (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1 ml-auto">
|
||||
<AlertTriangle className="w-3 h-3" /> Anonymisiert
|
||||
@@ -186,8 +193,9 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 p-4 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<p className="text-sm text-red-300 font-semibold">
|
||||
⚠️ Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.
|
||||
<p className="text-sm text-red-300 font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />
|
||||
<span>Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.</span>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
|
||||
@@ -1,41 +1,27 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||
import type { EndCustomer } from '@/lib/types'
|
||||
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
||||
import { getPartnerCustomersWithDevices } from '@/lib/actions/queries'
|
||||
import type { EndCustomerWithDevices } from '@/lib/types'
|
||||
import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { CustomerAccordionList } from '@/components/customer-accordion-list'
|
||||
|
||||
export default async function MyCustomersPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/auth/login')
|
||||
|
||||
let customers: EndCustomer[] = []
|
||||
let customers: EndCustomerWithDevices[] = []
|
||||
let fetchError: string | null = null
|
||||
try {
|
||||
customers = await getEndCustomers()
|
||||
customers = await getPartnerCustomersWithDevices()
|
||||
} catch (err: any) {
|
||||
console.error("Error loading end customers:", err)
|
||||
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden."
|
||||
console.error("Error loading partner customers with orders:", err)
|
||||
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen."
|
||||
}
|
||||
|
||||
// Anzahl Bestellungen pro Endkunde
|
||||
const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
|
||||
if (orderError) {
|
||||
console.error("Error loading order counts:", orderError)
|
||||
}
|
||||
|
||||
const countMap: Record<string, number> = {}
|
||||
;(orderCounts ?? []).forEach(o => {
|
||||
if (o.end_customer_id) {
|
||||
countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 1
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
@@ -50,10 +36,10 @@ export default async function MyCustomersPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
Meine Kunden
|
||||
Kunden & Kassen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Verwalten Sie Ihre Endkunden – deren Daten werden für Bestellungen verwendet.
|
||||
Verwalten Sie Ihre Kunden und deren zugewiesene Kassen / Abonnements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,86 +56,8 @@ export default async function MyCustomersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Kunden */}
|
||||
{customers.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Building2 className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
|
||||
<Link href="/my-customers/new">
|
||||
<Button><Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Kundenliste */}
|
||||
{customers.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left">
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Unternehmen</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden sm:table-cell">Adresse</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden md:table-cell">Bestellungen</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{customers.map(customer => (
|
||||
<tr key={customer.id} className="hover:bg-white/3 transition-colors">
|
||||
<td className="py-4 pr-4">
|
||||
<p className="font-semibold text-white">{customer.company_name}</p>
|
||||
{(customer.first_name || customer.last_name) && (
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
)}
|
||||
{customer.vat_id && (
|
||||
<p className="text-xs text-slate-500">{customer.vat_id}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 pr-4 hidden sm:table-cell">
|
||||
<p className="text-sm text-slate-300">
|
||||
{customer.street ?? '–'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.zip, customer.city].filter(Boolean).join(' ') || '–'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-4 pr-4 hidden md:table-cell">
|
||||
<Link href={`/my-orders?customer=${customer.id}`} className="group">
|
||||
<span className="text-primary font-bold group-hover:underline">
|
||||
{countMap[customer.id] ?? 0}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs ml-1">Bestellung(en)</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-4 pr-4">
|
||||
{customer.is_anonymized ? (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Anonymisiert
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">Aktiv</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
{!customer.is_anonymized && (
|
||||
<Link href={`/my-customers/${customer.id}`}>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white gap-1">
|
||||
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Akkordeon-Ansicht */}
|
||||
<CustomerAccordionList customers={customers} />
|
||||
|
||||
{/* DSGVO-Hinweis */}
|
||||
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 text-xs text-amber-300/80 space-y-1">
|
||||
|
||||
386
shop/app/my-orders/MyOrdersClient.tsx
Normal file
386
shop/app/my-orders/MyOrdersClient.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package, Search, ChevronLeft, ChevronRight, Building2, Monitor, RefreshCw, FileText } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { Order } from '@/lib/types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
interface MyOrdersClientProps {
|
||||
orders: Order[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function MyOrdersClient({ orders, error }: MyOrdersClientProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
|
||||
// Volltextsuche filtern
|
||||
const filteredOrders = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return orders
|
||||
|
||||
return orders.filter(o => {
|
||||
// Anfragenummer
|
||||
if (o.order_number?.toString().toLowerCase().includes(term)) return true
|
||||
|
||||
// Status
|
||||
const label = statusLabel[o.status] || o.status
|
||||
if (label.toLowerCase().includes(term) || o.status.toLowerCase().includes(term)) return true
|
||||
|
||||
// Datum
|
||||
const dateStr = new Date(o.created_at).toLocaleDateString('de-DE')
|
||||
if (dateStr.includes(term)) return true
|
||||
|
||||
// Endkunde (Firma / Name)
|
||||
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
||||
if (customer) {
|
||||
if (customer.company_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.first_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.last_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.customer_number?.toLowerCase().includes(term)) return true
|
||||
}
|
||||
|
||||
// Notizen
|
||||
if (o.notes?.toLowerCase().includes(term)) return true
|
||||
|
||||
// Kassen & Module
|
||||
const items = o.order_data?.items ?? []
|
||||
for (const item of items) {
|
||||
if (item.device_name?.toLowerCase().includes(term)) return true
|
||||
if (item.product_name?.toLowerCase().includes(term)) return true
|
||||
if (item.license_number?.toLowerCase().includes(term)) return true
|
||||
|
||||
for (const mod of item.selected_modules ?? []) {
|
||||
if (mod.module_name?.toLowerCase().includes(term)) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}, [orders, searchTerm])
|
||||
|
||||
// Paginierung berechnen
|
||||
const totalPages = Math.ceil(filteredOrders.length / pageSize) || 1
|
||||
const paginatedOrders = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return filteredOrders.slice(start, start + pageSize)
|
||||
}, [filteredOrders, currentPage, pageSize])
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||
Meine Anfragen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Alle Ihre Bestellungen und Konfigurationen auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/order">
|
||||
<Button className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_20px_rgba(59,130,246,0.3)]">
|
||||
+ Neue Anfrage erstellen
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Suche & Statistik */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Suchen nach Anfragenr., Kunde, Kasse, Produkt, Lizenz..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10 bg-slate-900/80 border-white/10 text-white placeholder:text-slate-500 rounded-xl h-11 text-sm focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 bg-slate-900/50 px-4 py-2.5 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
|
||||
<span>Anfragen:</span>
|
||||
<span className="text-white font-bold text-sm">{filteredOrders.length}</span>
|
||||
{searchTerm && <span className="text-slate-500">(gefiltert aus {orders.length})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||
Fehler beim Laden der Anfragen: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Anfragen (aus DB) */}
|
||||
{!error && orders.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Package className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||
<Link href="/order">
|
||||
<Button>Jetzt konfigurieren</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Keine Treffer nach Suche */}
|
||||
{!error && orders.length > 0 && filteredOrders.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Search className="w-10 h-10 text-slate-600" />
|
||||
<p className="text-slate-300 font-medium">Keine Anfragen für "{searchTerm}" gefunden.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Anfragenliste */}
|
||||
{paginatedOrders.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{paginatedOrders.map((o) => {
|
||||
const items = o.order_data?.items ?? []
|
||||
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
||||
|
||||
return (
|
||||
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-all duration-300 rounded-2xl overflow-hidden shadow-xl">
|
||||
<CardContent className="p-6 space-y-5">
|
||||
{/* Header-Zeile der Karte */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap pb-4 border-b border-white/5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Anfragenummer</span>
|
||||
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
||||
{statusLabel[o.status] ?? o.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="font-mono font-extrabold text-primary text-xl">#{o.order_number}</p>
|
||||
</div>
|
||||
|
||||
{/* Endkunde Details (falls vorhanden) */}
|
||||
{customer && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 px-3 flex items-center gap-3">
|
||||
<Building2 className="w-4 h-4 text-sky-400 shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="text-white font-bold">{customer.company_name || `${customer.first_name} ${customer.last_name}`}</p>
|
||||
{(customer.first_name || customer.city) && (
|
||||
<p className="text-slate-400 text-[11px]">
|
||||
{[customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : null, customer.city].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 text-right ml-auto">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Datum & Preis</p>
|
||||
<p className="text-slate-300 text-xs">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="font-extrabold text-white text-lg">
|
||||
{fmt(o.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kassen & Produkte Detail-Übersicht (Angelehnt an Kundenliste DeviceCard) */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||
<Monitor className="w-3.5 h-3.5 text-primary" />
|
||||
Kassenaufstellung ({items.length} {items.length === 1 ? 'Kasse' : 'Kassen'}):
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{items.map((item, idx) => {
|
||||
const devName = item.device_name || `Kasse ${idx + 1}`
|
||||
const licNum = item.license_number
|
||||
const modules = item.selected_modules || []
|
||||
|
||||
// Chips für Hauptprodukt und Module
|
||||
const chips: string[] = []
|
||||
if (item.product_name) chips.push(item.product_name)
|
||||
for (const mod of modules) {
|
||||
if (mod.module_name) chips.push(mod.module_name)
|
||||
}
|
||||
|
||||
const itemPrice = (item as any).price || item.base_price || 0
|
||||
const modulesPrice = modules.reduce((sum: number, m: any) => sum + ((m.price || 0) * (m.quantity || 1)), 0)
|
||||
const deviceTotal = itemPrice + modulesPrice
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden"
|
||||
>
|
||||
{/* Kassen-Info links */}
|
||||
<div className="space-y-2 min-w-[200px] flex-1">
|
||||
{/* Kassen-Name + Lizenznummer daneben + Intervall */}
|
||||
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
||||
{devName}
|
||||
</span>
|
||||
|
||||
{licNum && (
|
||||
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
||||
{licNum}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
|
||||
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Produkt + Modul Chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{chips.map((chip, cIdx) => (
|
||||
<span
|
||||
key={cIdx}
|
||||
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preis rechts */}
|
||||
{deviceTotal > 0 && (
|
||||
<div className="text-left md:text-right shrink-0 border-t md:border-t-0 pt-2 md:pt-0 border-white/5">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Kassenwert</p>
|
||||
<p className="font-bold text-white text-sm">
|
||||
{fmt(deviceTotal)}
|
||||
{item.billing_interval === 'monthly' && <span className="text-xs font-normal text-slate-400"> / mtl.</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notizen (falls vorhanden) */}
|
||||
{o.notes && (
|
||||
<div className="bg-slate-900/40 p-2.5 rounded-xl border border-white/5 text-xs text-slate-400 flex items-start gap-2">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
|
||||
<p><span className="font-semibold text-slate-300">Anmerkungen:</span> {o.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aktionsleiste */}
|
||||
<div className="flex gap-2 flex-wrap pt-2 border-t border-white/5 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link href={`/order/success?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs font-bold rounded-xl h-8">
|
||||
Details ansehen
|
||||
</Button>
|
||||
</Link>
|
||||
{o.status !== 'completed' && (
|
||||
<Link href={`/order?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-amber-500/30 hover:bg-amber-500/10 hover:border-amber-500/50 text-amber-400 text-xs font-bold gap-1 rounded-xl h-8">
|
||||
<RefreshCw className="w-3 h-3" /> Konfiguration bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{o.pdf_url && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
||||
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Ansehen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
||||
<a href={`/api/orders/${o.id}/download`} download>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginierung (10 pro Seite) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite <span className="text-white font-bold">{currentPage}</span> von <span className="text-white font-bold">{totalPages}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Vorherige
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl"
|
||||
>
|
||||
Nächste <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Order } from '@/lib/types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
import { MyOrdersClient } from './MyOrdersClient'
|
||||
|
||||
export default async function MyOrdersPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -47,134 +27,9 @@ export default async function MyOrdersPage() {
|
||||
const { data: orders, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||
Meine Anfragen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Alle Ihre Anfragen auf einen Blick – inkl. aktuellem Status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||
Fehler beim Laden der Anfragen: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Anfragen */}
|
||||
{!error && (!orders || orders.length === 0) && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Package className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||
<Link href="/order">
|
||||
<Button>Jetzt konfigurieren</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Anfragenliste */}
|
||||
<div className="space-y-4">
|
||||
{(orders ?? []).map((order) => {
|
||||
const o = order as Order
|
||||
const items = o.order_data?.items ?? []
|
||||
return (
|
||||
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-colors">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{/* Kopfzeile */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Anfragenummer</p>
|
||||
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Datum</p>
|
||||
<p className="text-white text-sm">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
|
||||
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
||||
{statusLabel[o.status] ?? o.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Gesamt</p>
|
||||
<p className="font-bold text-white text-lg">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Produkte kurz */}
|
||||
{items.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item, i) => (
|
||||
<span
|
||||
key={item.product_id}
|
||||
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
||||
>
|
||||
{item.product_name}
|
||||
{item.selected_modules.length > 0 && (
|
||||
<span className="text-slate-500 ml-1">+{item.selected_modules.length} Module</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="flex gap-2 flex-wrap pt-1">
|
||||
<Link href={`/order/success?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs">
|
||||
Details ansehen
|
||||
</Button>
|
||||
</Link>
|
||||
{o.status !== 'completed' && (
|
||||
<Link href={`/order?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-amber-500/20 hover:bg-amber-500/10 hover:border-amber-500/40 text-amber-400 text-xs">
|
||||
Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{o.pdf_url && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||
<a href={`/api/orders/${o.id}/download`} download>
|
||||
<Download className="w-3 h-3 mr-1" /> PDF
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MyOrdersClient
|
||||
orders={(orders as Order[]) || []}
|
||||
error={error ? error.message : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
90
shop/app/not-found.tsx
Normal file
90
shop/app/not-found.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
<AsciiShaderBackground />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center justify-center min-h-screen px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-center space-y-8 max-w-lg"
|
||||
>
|
||||
{/* Große 404 */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.1, type: 'spring', stiffness: 100 }}
|
||||
className="text-[10rem] md:text-[14rem] font-black leading-none tracking-tighter bg-gradient-to-b from-white via-slate-300 to-slate-600 bg-clip-text text-transparent select-none"
|
||||
>
|
||||
404
|
||||
</motion.h1>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white">
|
||||
Seite nicht gefunden
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm md:text-base leading-relaxed max-w-md mx-auto">
|
||||
Die angeforderte Seite existiert nicht oder wurde verschoben.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-4"
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 px-8 text-base gap-2 bg-gradient-to-r from-blue-600 to-sky-500 hover:from-blue-500 hover:to-sky-400 border-0 shadow-[0_0_20px_rgba(56,189,248,0.25)] hover:shadow-[0_0_30px_rgba(56,189,248,0.4)] transition-all"
|
||||
>
|
||||
<Link href="/">
|
||||
<Home className="w-4 h-4" />
|
||||
Zur Startseite
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="h-12 px-8 text-base gap-2 text-slate-400 hover:text-white border border-white/10 hover:border-white/20 hover:bg-white/5"
|
||||
onClick={() => typeof window !== 'undefined' && window.history.back()}
|
||||
>
|
||||
<button type="button">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück
|
||||
</button>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Subtle info */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.7 }}
|
||||
className="text-xs text-slate-600 pt-8"
|
||||
>
|
||||
Fehlercode 404 · CASPOS Shop
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,32 +8,41 @@ import { redirect } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
searchParams: Promise<{ id?: string; orderId?: string; mode?: string; customer_id?: string; device_id?: string }>
|
||||
}
|
||||
|
||||
export default async function OrderPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const orderId = params.orderId || params.id
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
|
||||
<div className="container mx-auto py-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight mb-4 text-gradient">
|
||||
Konfigurieren Sie Ihre Lösung
|
||||
</h1>
|
||||
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
|
||||
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
|
||||
<OrderDataWrapper orderId={params.id} />
|
||||
<OrderDataWrapper
|
||||
orderId={orderId}
|
||||
mode={params.mode}
|
||||
customerId={params.customer_id}
|
||||
deviceId={params.device_id}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
async function OrderDataWrapper({
|
||||
orderId,
|
||||
mode,
|
||||
customerId,
|
||||
deviceId,
|
||||
}: {
|
||||
orderId?: string
|
||||
mode?: string
|
||||
customerId?: string
|
||||
deviceId?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
@@ -48,7 +57,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = userData?.role === 'admin'
|
||||
const isAdmin = userData?.role === 'admin' || userData?.role === 'verwaltung'
|
||||
const hasCompany = !!userData?.company_id
|
||||
|
||||
if (!isAdmin && !hasCompany) {
|
||||
@@ -76,14 +85,14 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
const isAdmin = userData?.role === 'admin'
|
||||
const isAdmin = userData?.role === 'admin' || userData?.role === 'verwaltung'
|
||||
const isOwner = orderData.user_id === user.id
|
||||
const isCompanyMember = userData?.company_id && orderData.company_id === userData.company_id
|
||||
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
if (orderData.status === 'completed' && !isAdmin) {
|
||||
if (orderData.status === 'completed' && !isAdmin && mode !== 'extension' && mode !== 'upgrade') {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
@@ -95,7 +104,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
|
||||
// Admin sees all end customers across partners
|
||||
let endCustomers = []
|
||||
if (userData?.role === 'admin') {
|
||||
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data } = await adminClient
|
||||
@@ -125,10 +134,14 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
|
||||
// Fetch companies list for admin
|
||||
let companies: any[] = []
|
||||
if (userData?.role === 'admin') {
|
||||
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
|
||||
companies = await getCompanies().catch(() => [])
|
||||
}
|
||||
|
||||
const initialLastLicenseDate = initialOrder
|
||||
? (initialOrder.order_data?.last_license_date || (initialOrder.created_at ? new Date(initialOrder.created_at).toISOString().split('T')[0] : ''))
|
||||
: ''
|
||||
|
||||
return (
|
||||
<OrderWizard
|
||||
products={products}
|
||||
@@ -136,8 +149,12 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
initialProfile={profile}
|
||||
initialEndCustomers={endCustomers}
|
||||
initialOrder={initialOrder}
|
||||
isAdmin={userData?.role === 'admin'}
|
||||
isAdmin={userData?.role === 'admin' || userData?.role === 'verwaltung'}
|
||||
companies={companies}
|
||||
upgradeMode={mode === 'upgrade'}
|
||||
initialEndCustomerId={customerId || null}
|
||||
lockedDeviceId={deviceId ? decodeURIComponent(deviceId) : null}
|
||||
initialLastLicenseDate={initialLastLicenseDate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function OrderSuccessPage({
|
||||
if (error || !order) redirect('/')
|
||||
|
||||
// Berechtigung prüfen
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
|
||||
const isOwner = order.user_id === user.id
|
||||
const isCompanyMember = dbUser?.company_id && order.company_id === dbUser.company_id
|
||||
|
||||
@@ -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">
|
||||
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `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>
|
||||
@@ -144,7 +159,7 @@ export default async function OrderSuccessPage({
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto Gesamtbetrag:</span>
|
||||
<span className={monthlyNet > 0 ? "text-white" : "text-gradient"}>
|
||||
<span className={monthlyNet > 0 ? "text-white" : "text-primary"}>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -168,7 +183,7 @@ export default async function OrderSuccessPage({
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto Gesamtbetrag:</span>
|
||||
<span className="text-gradient">
|
||||
<span className="text-primary">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / Monat
|
||||
</span>
|
||||
</div>
|
||||
|
||||
16
shop/app/wizard/page.tsx
Normal file
16
shop/app/wizard/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ id?: string; orderId?: string; mode?: string }>
|
||||
}
|
||||
|
||||
export default async function WizardPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const id = params.orderId || params.id
|
||||
const mode = params.mode
|
||||
const query = new URLSearchParams()
|
||||
if (id) query.set('orderId', id)
|
||||
if (mode) query.set('mode', mode)
|
||||
|
||||
redirect(`/order?${query.toString()}`)
|
||||
}
|
||||
180
shop/components/AsciiShaderBackground.tsx
Normal file
180
shop/components/AsciiShaderBackground.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let animationFrameId: number
|
||||
|
||||
// Minimalistisches, ruhiges ASCII-Array
|
||||
const chars = ['•', '∘', '+', '▪', '▫', ' ']
|
||||
const fontSize = 18
|
||||
let cols = 0
|
||||
let rows = 0
|
||||
|
||||
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }
|
||||
let scrollProgress = 0
|
||||
let targetScrollProgress = 0
|
||||
|
||||
const getDynamicHue = (): number => {
|
||||
try {
|
||||
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return Math.round(h * 360)
|
||||
} catch (e) {
|
||||
return 217
|
||||
}
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
cols = Math.ceil(width / fontSize)
|
||||
rows = Math.ceil(height / fontSize)
|
||||
|
||||
if (mouse.x === 0 && mouse.y === 0) {
|
||||
mouse.x = width / 2
|
||||
mouse.y = height / 2
|
||||
mouse.targetX = width / 2
|
||||
mouse.targetY = height / 2
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
mouse.targetX = e.clientX
|
||||
mouse.targetY = e.clientY
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const maxScroll = Math.max(
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
1
|
||||
)
|
||||
targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
const render = (time: number) => {
|
||||
mouse.x += (mouse.targetX - mouse.x) * 0.05
|
||||
mouse.y += (mouse.targetY - mouse.y) * 0.05
|
||||
scrollProgress += (targetScrollProgress - scrollProgress) * 0.05
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Beim Scrollen sofort ausblenden (vollständig weg ab 150px Scroll-Tiefe)
|
||||
const scrollFade = Math.max(1 - (window.scrollY / 150), 0)
|
||||
if (scrollFade <= 0) {
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const timeSec = time * 0.001
|
||||
const activeHue = getDynamicHue()
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * fontSize + fontSize / 2
|
||||
const y = r * fontSize + fontSize / 2
|
||||
|
||||
const dx = x - mouse.x
|
||||
const dy = y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
const mouseInfluence = Math.exp(-dist / 250)
|
||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
||||
|
||||
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
|
||||
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||
|
||||
const normalizedFlow = (flowValue + 1) * 0.5
|
||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||
const char = chars[charIndex]
|
||||
|
||||
if (char === ' ') continue
|
||||
|
||||
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
|
||||
const lightness = 40 + Math.round(blendFactor * 50)
|
||||
|
||||
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
|
||||
ctx.fillText(char, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full block will-change-transform pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-60"
|
||||
style={{
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface HeaderWrapperProps {
|
||||
navbar: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
@@ -17,21 +19,24 @@ export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isWizard = pathname === "/order" || pathname === "/wizard";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
|
||||
<div className={`flex flex-col ${isWizard ? "h-screen overflow-hidden" : "min-h-screen"} bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white`}>
|
||||
{/* Demo Banner */}
|
||||
<DemoWrapper>
|
||||
<div
|
||||
id="demo-banner"
|
||||
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50"
|
||||
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.</span>
|
||||
</div>
|
||||
</DemoWrapper>
|
||||
|
||||
{navbar}
|
||||
|
||||
<main className="flex-1">
|
||||
<main className={`flex-1 ${isWizard ? "overflow-hidden" : ""}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -18,17 +18,34 @@ import {
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
ShieldCheck,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Activity
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ProcessSteps } from '@/components/ProcessSteps'
|
||||
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
||||
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
||||
import { useTheme } from '@/components/ThemeProvider'
|
||||
|
||||
interface HomeClientProps {
|
||||
initialUser: User | null
|
||||
}
|
||||
|
||||
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
const { branding } = useTheme()
|
||||
const [user, setUser] = useState<User | null>(initialUser)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -68,39 +85,10 @@ 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">
|
||||
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
<AsciiShaderBackground />
|
||||
|
||||
|
||||
{/* 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 +98,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 +121,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,51 +138,71 @@ 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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 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 h-full"
|
||||
>
|
||||
<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">
|
||||
<Users className="w-5.5 h-5.5" />
|
||||
<div className="flex items-center gap-3">
|
||||
<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 shrink-0">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Kundenliste</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Kunden & Lizenzen verwalten</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Verwalten</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
<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 h-full"
|
||||
>
|
||||
<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">
|
||||
<ClipboardList className="w-5.5 h-5.5" />
|
||||
<div className="flex items-center gap-3">
|
||||
<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 shrink-0">
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Bestellungen</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Aufträge & Status einsehen</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Status einsehen</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
<button type="button" onClick={() => setCalendarOpen(true)} className="group text-left w-full">
|
||||
<motion.div
|
||||
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 h-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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 shrink-0">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Termine</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-md">Geplant</span>
|
||||
</div>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Terminkalender</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -216,141 +211,65 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Grid of modules */}
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative">
|
||||
<div className="container max-w-6xl mx-auto px-4 relative">
|
||||
|
||||
<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
|
||||
{/* Dialog / Popup für Termin-Kalender (Geplant) */}
|
||||
<Dialog open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 text-slate-100 max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Termin-Kalender
|
||||
<span className="px-2 py-0.5 text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-full">
|
||||
In Planung
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</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">
|
||||
Workspace-Zentrale
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
|
||||
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
|
||||
</p>
|
||||
<DialogDescription className="text-slate-400 text-sm leading-relaxed pt-2">
|
||||
Der Termin-Kalender bietet in Kürze eine Übersicht für anstehende Schulungen, Online- & Vor-Ort-Workshops sowie Termine für Blauer-Mittwoch-Partner zur direkten Anmeldung.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-4 my-2 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-400 space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-300 font-medium">
|
||||
<span>Geplante Features & Angebote:</span>
|
||||
</div>
|
||||
<ul className="list-disc list-inside space-y-1 text-slate-400">
|
||||
<li>Anmeldung zu Online- & Vor-Ort-Workshops</li>
|
||||
<li>Anstehende Schulungstermine auf einen Blick</li>
|
||||
<li>Spezielle Schulungen für Blauer-Mittwoch-Partner</li>
|
||||
<li>Kalender-Export (.ics / Google / Outlook)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCalendarOpen(false)}
|
||||
className="w-full sm:w-auto border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
{/* 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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Zulip</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{/* 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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Wiki</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Ihr Wissens-Portal für die CASPOS Produktfamilie.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{/* 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" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Support</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Grid of modules / Workspace-Zentrale */}
|
||||
<WorkspaceZentrale />
|
||||
|
||||
{/* Stepper info */}
|
||||
<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">
|
||||
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="grid gap-8 md:grid-cols-3 relative z-10">
|
||||
{/* Step 1 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
1
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
2
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
3
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Nach dem Erhalt der Lizenz Live gehen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ProcessSteps />
|
||||
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
||||
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
|
||||
</p>
|
||||
<nav className="flex gap-6">
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface NavbarClientProps {
|
||||
user: User | null;
|
||||
@@ -18,6 +19,7 @@ interface NavbarClientProps {
|
||||
|
||||
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
const router = useRouter();
|
||||
const { branding } = useTheme();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||
const [userRole, setUserRole] = useState<string>(role);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<>
|
||||
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
{branding?.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
|
||||
) : branding?.companyName ? (
|
||||
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
|
||||
{branding.companyName}
|
||||
</span>
|
||||
) : (
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
)}
|
||||
<DemoWrapper>
|
||||
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||
Demo
|
||||
@@ -110,7 +120,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"
|
||||
>
|
||||
@@ -136,17 +146,20 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<div className="h-4 w-px bg-white/10 mx-2" />
|
||||
|
||||
{currentUser ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="border-white/10 hover:bg-white/5 gap-2 text-white"
|
||||
>
|
||||
<UserIcon className="w-4 h-4 text-primary" />
|
||||
<span className="max-w-[120px] truncate">{currentUser.email}</span>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10 before:absolute before:-top-3 before:left-0 before:w-full before:h-3">
|
||||
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
|
||||
{currentUser.email}
|
||||
</div>
|
||||
@@ -197,7 +210,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
|
||||
<Button asChild className="bg-primary hover:bg-primary/95 text-white">
|
||||
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -287,7 +300,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<LogOut className="w-4 h-4" /> Abmelden
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
155
shop/components/ProcessSteps.tsx
Normal file
155
shop/components/ProcessSteps.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface Step {
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Kunde wählen',
|
||||
description: 'Gewünschten Kunden auswählen, der die Lizenz erhalten soll.'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Lizenz wählen',
|
||||
description: 'Gewünschte Lizenzen für den Kunden auswählen und die Bestellung abschließen.'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Live gehen',
|
||||
description: 'Nach dem Erhalt der Lizenz direkt live gehen und durchstarten.'
|
||||
}
|
||||
]
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.2,
|
||||
staggerChildren: 0.35
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stepVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring' as const,
|
||||
stiffness: 70,
|
||||
damping: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ProcessSteps() {
|
||||
return (
|
||||
<section className="w-full py-16 px-4 relative z-10">
|
||||
<div className="container max-w-5xl mx-auto flex flex-col items-center">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ type: 'spring' as const, stiffness: 60, damping: 15 }}
|
||||
className="text-center space-y-3 mb-16 max-w-2xl"
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
|
||||
Wie funktioniert die Bereitstellung?
|
||||
</h2>
|
||||
<p className="text-slate-400 text-base sm:text-lg leading-relaxed">
|
||||
In nur drei einfachen Schritten richten Sie CASPOS Lizenzen für Ihre Kunden ein.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Horizontale Timeline */}
|
||||
<div className="relative w-full">
|
||||
{/* Basis-Linie exakt zwischen Schritt 1 und 3 */}
|
||||
<div className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-white/10 z-0 rounded-full" />
|
||||
|
||||
{/* Animierte, aufgeladene Linie (sky-500) */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2
|
||||
}}
|
||||
className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-sky-500 origin-left z-0 rounded-full shadow-[0_0_12px_rgba(14,165,233,0.8)]"
|
||||
/>
|
||||
|
||||
{/* 3 Schritte Nebeneinander */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6 w-full relative z-10"
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isLiveStep = index === 2
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={step.number}
|
||||
variants={stepVariants}
|
||||
className="group flex flex-col items-center text-center space-y-4 cursor-pointer relative"
|
||||
>
|
||||
{/* Nummer im runden Kreis auf der Linie */}
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.15 }}
|
||||
transition={{ type: 'spring' as const, stiffness: 300, damping: 20 }}
|
||||
className="w-12 h-12 rounded-full bg-slate-950 border-2 border-slate-700 text-slate-300 font-extrabold text-lg flex items-center justify-center group-hover:border-sky-400 group-hover:text-sky-400 group-hover:bg-slate-900 group-hover:shadow-[0_0_20px_rgba(56,189,248,0.4)] transition-all duration-300 z-10 relative"
|
||||
>
|
||||
{step.number}
|
||||
</motion.div>
|
||||
|
||||
{/* Pulsierender Ziel-Ring bei Schritt 3 */}
|
||||
{isLiveStep && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 1.6 }}
|
||||
className="absolute inset-0 rounded-full border-2 border-emerald-400/60 pointer-events-none animate-ping"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text-Inhalt zentriert ohne Boxen-Hintergrund */}
|
||||
<div className="space-y-2 max-w-[280px]">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-sky-400 transition-colors duration-300 flex items-center justify-center gap-2">
|
||||
{step.title}
|
||||
{isLiveStep && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
24
shop/components/ScrollIndicator.tsx
Normal file
24
shop/components/ScrollIndicator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useSpring } from 'framer-motion'
|
||||
|
||||
export function ScrollIndicator() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
|
||||
style={{
|
||||
scaleX,
|
||||
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
|
||||
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3,30 +3,47 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User, MapPin, Receipt, Palette, Send } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { completeSetup } from '@/lib/actions/setup'
|
||||
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
|
||||
|
||||
export function SetupWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
|
||||
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Step 2 Form: Admin account
|
||||
// Step 2: Admin Account
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
// Step 3 Form: SMTP Config
|
||||
// Step 3: Firmendaten & Rechnungsadresse
|
||||
const [brandingForm, setBrandingForm] = useState({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
})
|
||||
|
||||
// Step 5: SMTP Config
|
||||
const [smtpForm, setSmtpForm] = useState({
|
||||
host: '',
|
||||
port: '587',
|
||||
@@ -35,36 +52,67 @@ export function SetupWizard() {
|
||||
pass: '',
|
||||
})
|
||||
|
||||
// Validation checks for Admin form
|
||||
// Validations
|
||||
const isAdminFormValid =
|
||||
adminForm.email.includes('@') &&
|
||||
adminForm.password.length >= 6 &&
|
||||
adminForm.password === adminForm.confirmPassword &&
|
||||
adminForm.companyName.trim().length > 0 &&
|
||||
adminForm.firstName.trim().length > 0 &&
|
||||
adminForm.lastName.trim().length > 0
|
||||
|
||||
// Validation checks for SMTP form
|
||||
const isSmtpFormValid =
|
||||
smtpForm.host.trim().length > 0 &&
|
||||
!isNaN(Number(smtpForm.port)) &&
|
||||
smtpForm.user.trim().length > 0
|
||||
const isCompanyFormValid =
|
||||
brandingForm.companyName.trim().length > 0 &&
|
||||
brandingForm.street.trim().length > 0 &&
|
||||
brandingForm.zip.trim().length > 0 &&
|
||||
brandingForm.city.trim().length > 0 &&
|
||||
(brandingForm.sameBillingAddress ||
|
||||
(brandingForm.billingStreet.trim().length > 0 &&
|
||||
brandingForm.billingZip.trim().length > 0 &&
|
||||
brandingForm.billingCity.trim().length > 0))
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTestSmtpLoading(true)
|
||||
setTestSmtpResult(null)
|
||||
const res = await testSmtpConfig(
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
},
|
||||
adminForm.email
|
||||
)
|
||||
setTestSmtpResult(res)
|
||||
setTestSmtpLoading(false)
|
||||
}
|
||||
|
||||
const handleFinishSetup = async () => {
|
||||
if (!isAdminFormValid || !isSmtpFormValid) return
|
||||
if (!isAdminFormValid || !isCompanyFormValid) return
|
||||
setLoading(true)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
// 1. Submit details via server action
|
||||
const res = await completeSetup(
|
||||
{
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
companyName: adminForm.companyName,
|
||||
companyName: brandingForm.companyName,
|
||||
firstName: adminForm.firstName,
|
||||
lastName: adminForm.lastName,
|
||||
},
|
||||
{
|
||||
street: brandingForm.street,
|
||||
zip: brandingForm.zip,
|
||||
city: brandingForm.city,
|
||||
billingStreet: brandingForm.billingStreet,
|
||||
billingZip: brandingForm.billingZip,
|
||||
billingCity: brandingForm.billingCity,
|
||||
sameBillingAddress: brandingForm.sameBillingAddress,
|
||||
colorScheme: brandingForm.colorScheme,
|
||||
primaryColor: brandingForm.primaryColor,
|
||||
accentColor: brandingForm.accentColor,
|
||||
},
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
@@ -80,20 +128,8 @@ export function SetupWizard() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Automatischer Login für flüssiges Erlebnis
|
||||
const supabase = createClient()
|
||||
const { error: loginError } = await supabase.auth.signInWithPassword({
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
})
|
||||
|
||||
if (loginError) {
|
||||
console.error('Auto login failed:', loginError)
|
||||
// Redirect anyway since setup is done
|
||||
}
|
||||
|
||||
// 3. Weiterleitung
|
||||
router.push('/')
|
||||
// Setup erfolgreich – Weiterleitung zur Login-Seite
|
||||
router.push('/auth/login?setup=success')
|
||||
router.refresh()
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||
@@ -103,19 +139,19 @@ export function SetupWizard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Dynamic Background Glow */}
|
||||
{/* Background Glow */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
|
||||
|
||||
<div className="w-full max-w-xl relative z-10">
|
||||
<div className="w-full max-w-2xl relative z-10 my-8">
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-8 px-2">
|
||||
<div className="flex items-center justify-between mb-6 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">B2B Shop</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">made by hephex</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3].map((s) => (
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
|
||||
@@ -126,13 +162,13 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* STEP 1: Willkommen */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
|
||||
@@ -140,9 +176,9 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -153,11 +189,19 @@ export function SetupWizard() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
|
||||
<span>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
|
||||
<span>Lizenzserver-Anbindung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Stammdatenverwaltung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Adress- & Rechnungsverwaltung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +215,13 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: Admin-Konto */}
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -186,7 +230,7 @@ export function SetupWizard() {
|
||||
Admin-Konto anlegen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
|
||||
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -218,19 +262,6 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={adminForm.companyName}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
|
||||
placeholder="Name Ihrer Firma"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
|
||||
<div className="relative">
|
||||
@@ -275,7 +306,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
@@ -283,6 +314,203 @@ export function SetupWizard() {
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!isAdminFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu Firmendaten
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Firmendaten & Rechnungsadresse */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Building className="w-6 h-6 text-blue-400" />
|
||||
Firmendaten & Adressen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Firmenname *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={brandingForm.companyName}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
|
||||
placeholder="z. B. CASPOS Software GmbH"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anschrift */}
|
||||
<div className="space-y-3 pt-1 border-t border-slate-800">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
|
||||
</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
|
||||
<Input
|
||||
value={brandingForm.street}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.zip}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.city}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechnungsadresse */}
|
||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingForm.sameBillingAddress}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Anschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!brandingForm.sameBillingAddress && (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingStreet}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingZip}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
|
||||
}
|
||||
placeholder="54321"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingCity}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(4)}
|
||||
disabled={!isCompanyFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zum Farbschema
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Farbschema & Live-Vorschau */}
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step4"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Palette className="w-6 h-6 text-blue-400" />
|
||||
Farbschema & Webshop Styling
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ColorThemePicker
|
||||
colorScheme={brandingForm.colorScheme}
|
||||
primaryColor={brandingForm.primaryColor}
|
||||
accentColor={brandingForm.accentColor}
|
||||
companyName={brandingForm.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBrandingForm({
|
||||
...brandingForm,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu SMTP
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
@@ -291,22 +519,27 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{/* STEP 5: SMTP Mailserver */}
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
key="step5"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver konfigurieren
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver
|
||||
</h2>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
|
||||
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +552,7 @@ export function SetupWizard() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Host</Label>
|
||||
<Input
|
||||
value={smtpForm.host}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
||||
@@ -328,7 +561,7 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Port</Label>
|
||||
<Input
|
||||
value={smtpForm.port}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
||||
@@ -339,7 +572,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Benutzername *</Label>
|
||||
<Label className="text-slate-300 text-xs">Benutzername</Label>
|
||||
<Input
|
||||
value={smtpForm.user}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
||||
@@ -359,32 +592,73 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
checked={smtpForm.secure}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
|
||||
</Label>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
checked={smtpForm.secure}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
|
||||
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
|
||||
>
|
||||
{testSmtpLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testSmtpResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-xs font-medium ${
|
||||
testSmtpResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testSmtpResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(2)}
|
||||
onClick={() => setStep(4)}
|
||||
disabled={loading}
|
||||
className="text-white hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFinishSetup}
|
||||
disabled={!isSmtpFormValid || loading}
|
||||
disabled={loading}
|
||||
className="border-slate-700 text-slate-300 hover:text-white"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinishSetup}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
111
shop/components/ThemeProvider.tsx
Normal file
111
shop/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { getBrandingSettings } from '@/lib/actions/branding'
|
||||
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
try {
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0, s = 0, l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
||||
} catch (e) {
|
||||
return '217 91% 60%'
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
branding: BrandingSettings | null
|
||||
refreshBranding: () => Promise<void>
|
||||
}>({
|
||||
branding: null,
|
||||
refreshBranding: async () => {},
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
[key: string]: any
|
||||
}) {
|
||||
const [branding, setBranding] = useState<BrandingSettings | null>(null)
|
||||
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
const settings = await getBrandingSettings()
|
||||
setBranding(settings)
|
||||
|
||||
// Preset Ermittlung
|
||||
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
const isCustom = settings.colorScheme === 'custom'
|
||||
|
||||
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
|
||||
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
|
||||
|
||||
const primaryHsl = hexToHsl(primary)
|
||||
const accentHsl = hexToHsl(accent)
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// 10 Color CSS Tokens applied globally onto :root
|
||||
root.style.setProperty('--primary', primaryHsl)
|
||||
root.style.setProperty('--ring', primaryHsl)
|
||||
root.style.setProperty('--accent', accentHsl)
|
||||
|
||||
root.style.setProperty('--primary-custom', primary)
|
||||
root.style.setProperty('--accent-custom', accent)
|
||||
|
||||
root.style.setProperty('--success-custom', settings.successColor || preset.success)
|
||||
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
|
||||
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
|
||||
|
||||
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
|
||||
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
|
||||
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
|
||||
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
|
||||
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
|
||||
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
|
||||
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
|
||||
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
|
||||
} catch (e) {
|
||||
console.error('Failed to load branding theme:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTheme()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider {...props}>
|
||||
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
132
shop/components/WorkspaceZentrale.tsx
Normal file
132
shop/components/WorkspaceZentrale.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MessageSquare, BookOpen, LifeBuoy, ExternalLink } from 'lucide-react'
|
||||
|
||||
interface WorkspaceItem {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
hoverText: string
|
||||
icon: React.ElementType
|
||||
link: string
|
||||
}
|
||||
|
||||
const workspaceItems: WorkspaceItem[] = [
|
||||
{
|
||||
id: 'zulip',
|
||||
title: 'Zulip Chat',
|
||||
subtitle: 'Treten Sie unserem Partner-Chat bei!',
|
||||
hoverText: 'Tauschen Sie sich direkt und unkompliziert mit anderen CASPOS Partnern aus.',
|
||||
icon: MessageSquare,
|
||||
link: 'https://caspos.zulipchat.com'
|
||||
},
|
||||
{
|
||||
id: 'wiki',
|
||||
title: 'CASPOS Wiki',
|
||||
subtitle: 'Ihr Wissens-Portal...',
|
||||
hoverText: '...für die gesamte CASPOS Produktfamilie. Hier finden Sie alle technischen Dokumentationen.',
|
||||
icon: BookOpen,
|
||||
link: 'https://wiki.caspos.de'
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support Portal',
|
||||
subtitle: 'Sie haben ein technisches Problem?',
|
||||
hoverText: 'Erstellen Sie jetzt ein Ticket in der CASPOS, damit sich unsere Experten darum kümmern.',
|
||||
icon: LifeBuoy,
|
||||
link: 'https://support.caspos.de'
|
||||
}
|
||||
]
|
||||
|
||||
export function WorkspaceZentrale() {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<section className="w-full py-12 px-4 relative z-10">
|
||||
<div className="container max-w-6xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
||||
Workspace Zentrale
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 3-Spalten Grid mit fester Kartenhöhe */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{workspaceItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isHovered = hoveredId === item.id
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
key={item.id}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ y: -4 }}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className={`group relative p-6 rounded-2xl border transition-all duration-300 backdrop-blur-md flex flex-col justify-between h-[210px] overflow-hidden cursor-pointer ${
|
||||
isHovered
|
||||
? 'border-sky-500 bg-slate-900/90 shadow-[0_0_25px_rgba(14,165,233,0.2)]'
|
||||
: 'border-slate-800 bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header: Icon & Link */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`p-3 rounded-xl border transition-all duration-300 ${
|
||||
isHovered
|
||||
? 'bg-sky-500/20 border-sky-500/50 text-sky-300'
|
||||
: 'bg-slate-800/60 border-slate-700/50 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<ExternalLink
|
||||
className={`w-4 h-4 transition-all duration-300 ${
|
||||
isHovered
|
||||
? 'text-sky-400 translate-x-0.5 -translate-y-0.5'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-sky-300 transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{/* Subtitle / Hover-Text Überblendung ohne Höhenänderung */}
|
||||
<div className="relative h-[48px] overflow-hidden">
|
||||
<p
|
||||
className={`text-sm text-slate-300 font-medium transition-all duration-300 absolute inset-0 ${
|
||||
isHovered ? 'opacity-0 translate-y-[-8px]' : 'opacity-100 translate-y-0'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs text-slate-400 leading-relaxed transition-all duration-300 absolute inset-0 ${
|
||||
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[8px]'
|
||||
}`}
|
||||
>
|
||||
{item.hoverText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtile Hover-Linie unten */}
|
||||
<div className={`w-full h-0.5 rounded-full transition-all duration-300 ${
|
||||
isHovered ? 'bg-sky-500/80 shadow-[0_0_8px_rgba(14,165,233,0.8)]' : 'bg-transparent'
|
||||
}`} />
|
||||
</motion.a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
267
shop/components/admin/ColorThemePicker.tsx
Normal file
267
shop/components/admin/ColorThemePicker.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
|
||||
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface ColorThemePickerProps {
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
companyName?: string
|
||||
onChange: (scheme: string, primary: string, accent: string) => void
|
||||
}
|
||||
|
||||
export function ColorThemePicker({
|
||||
colorScheme,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
companyName = 'CASPOS Shop',
|
||||
onChange,
|
||||
}: ColorThemePickerProps) {
|
||||
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
|
||||
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
|
||||
|
||||
const isCustom = colorScheme === 'custom'
|
||||
|
||||
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
|
||||
const handleSelectPreset = (preset: ColorPreset) => {
|
||||
onChange(preset.id, preset.primary, preset.accent)
|
||||
}
|
||||
|
||||
const handleCustomPrimaryChange = (val: string) => {
|
||||
setCustomPrimary(val)
|
||||
onChange('custom', val, customAccent)
|
||||
}
|
||||
|
||||
const handleCustomAccentChange = (val: string) => {
|
||||
setCustomAccent(val)
|
||||
onChange('custom', customPrimary, val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grid of Presets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-blue-400" />
|
||||
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PRESET_COLOR_SCHEMES.map((preset) => {
|
||||
const isSelected = colorScheme === preset.id
|
||||
|
||||
const tokenSwatches = [
|
||||
{ name: 'Primary', color: preset.primary, desc: 'Helle/edle Hauptfarbe für Navigation/Texte' },
|
||||
{ name: 'Accent', color: preset.accent, desc: 'Scharfe Highlight-Farbe für Badges/Meldungen' },
|
||||
{ name: 'bgGlow1', color: preset.bgGlow1, desc: 'Atmosphärischer Lichtnebel von oben' },
|
||||
{ name: 'bgGlow2', color: preset.bgGlow2, desc: 'Subtiles Gegenlicht von unten' },
|
||||
{ name: 'gradientFrom', color: preset.gradientFrom, desc: 'Startfarbe für Core-Verläufe' },
|
||||
{ name: 'gradientTo', color: preset.gradientTo, desc: 'Endfarbe für Core-Verläufe' },
|
||||
{ name: 'textHighlight', color: preset.textHighlight, desc: 'Soft-Pastell Text-Empfehlung' },
|
||||
{ name: 'buttonBg', color: preset.buttonBg, desc: 'Satte Basisfarbe Hauptbuttons' },
|
||||
{ name: 'ringColor', color: preset.ringColor, desc: 'Präzise Fokus-Rand-Farbe' },
|
||||
{ name: 'cardBorder', color: preset.cardBorder, desc: 'Subtiler Rahmen-Ton Karten' },
|
||||
]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
|
||||
isSelected
|
||||
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-xs text-white truncate pr-2">
|
||||
{preset.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* 10 Color Token Swatches Bar */}
|
||||
<div className="space-y-1 pt-1 border-t border-slate-800/60">
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
{tokenSwatches.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0 transition-transform hover:scale-125"
|
||||
style={{ backgroundColor: item.color }}
|
||||
title={`${item.name}: ${item.desc} (${item.color})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Picker */}
|
||||
<div
|
||||
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
|
||||
isCustom
|
||||
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/40 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('custom', customPrimary, customAccent)}
|
||||
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
|
||||
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
|
||||
}`}>
|
||||
{isCustom && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
|
||||
{/* Primary Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Micro-Preview Card with Animated Background Orbs */}
|
||||
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
|
||||
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
Live Vorschau & Animierter Background Glow
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">
|
||||
{primaryColor} / {accentColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
|
||||
{/* Animated Background Orbs Preview */}
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: 'var(--bg-glow-1, ' + (activePreset.bgGlow1 || primaryColor) + ')' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: 'var(--bg-glow-2, ' + (activePreset.bgGlow2 || accentColor) + ')' }}
|
||||
/>
|
||||
|
||||
{/* Header Preview */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
|
||||
style={{ backgroundColor: 'var(--primary-custom, ' + primaryColor + ')' }}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="font-bold text-xs text-white">
|
||||
{companyName || 'Ihr Unternehmen'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
|
||||
style={{ backgroundColor: 'var(--accent-custom, ' + accentColor + ')' }}
|
||||
>
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons & Badges Preview */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--gradient-from, ${primaryColor}) 0%, var(--gradient-to, ${activePreset.gradientTo || primaryColor}) 100%)`,
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
In den Warenkorb
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
|
||||
style={{ borderColor: 'var(--accent-custom, ' + accentColor + ')' }}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
|
||||
style={{
|
||||
borderColor: 'var(--card-border-glow, ' + accentColor + '40)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent-custom, ' + accentColor + ') 15%, transparent)',
|
||||
color: 'var(--text-highlight, ' + (activePreset.textHighlight || accentColor) + ')',
|
||||
}}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
|
||||
import { Plus, Trash2, X, PlusCircle, CreditCard, RefreshCw } from 'lucide-react'
|
||||
import { createProduct, updateProduct } from '@/lib/actions/products'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -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,212 @@ 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: CreditCard },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: RefreshCw },
|
||||
].map(opt => {
|
||||
const IconComponent = opt.icon;
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-5 h-5 mb-1" />
|
||||
{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 +480,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 +503,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 +594,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 +633,12 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
@@ -591,18 +676,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>
|
||||
|
||||
@@ -5,15 +5,42 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Download, ExternalLink, Search, Loader2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Loader2,
|
||||
Eye,
|
||||
ArrowUpDown,
|
||||
Building2,
|
||||
User,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
FileText,
|
||||
Calendar,
|
||||
Layers,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Edit3
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { updateOrderStatus } from "@/lib/actions/orders";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { updateOrderStatus, rejectOrder, updateOrderPayload } from "@/lib/actions/orders";
|
||||
|
||||
interface OrdersTableProps {
|
||||
initialOrders: any[];
|
||||
@@ -21,7 +48,10 @@ interface OrdersTableProps {
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
pending_approval: 'Wartet auf Freigabe',
|
||||
in_review: 'In Prüfung',
|
||||
approved: 'Freigegeben',
|
||||
active: 'Aktiviert',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
@@ -29,27 +59,147 @@ const statusLabel: Record<string, string> = {
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
|
||||
pending_approval: 'bg-amber-600/20 text-amber-300 border border-amber-500/40',
|
||||
in_review: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
|
||||
approved: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
|
||||
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
|
||||
cancelled: 'bg-slate-500/20 text-slate-400 border border-slate-500/30',
|
||||
rejected: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
};
|
||||
|
||||
type SortField = 'date_desc' | 'date_asc' | 'price_desc' | 'price_asc' | 'order_num';
|
||||
|
||||
export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
const [orders, setOrders] = useState(initialOrders);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<SortField>("date_desc");
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
|
||||
|
||||
// Rejection Dialog State
|
||||
const [rejectingOrder, setRejectingOrder] = useState<any | null>(null);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
|
||||
|
||||
// Preiskorrektur States
|
||||
const [isEditingSnapshot, setIsEditingSnapshot] = useState(false);
|
||||
const [editedSnapshot, setEditedSnapshot] = useState<any | null>(null);
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
|
||||
// Initialize editedSnapshot when selectedOrder changes
|
||||
useEffect(() => {
|
||||
if (selectedOrder) {
|
||||
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||
setIsEditingSnapshot(false);
|
||||
} else {
|
||||
setEditedSnapshot(null);
|
||||
setIsEditingSnapshot(false);
|
||||
}
|
||||
}, [selectedOrder]);
|
||||
|
||||
const updateItemBasePrice = (itemIdx: number, newPrice: number) => {
|
||||
if (!editedSnapshot) return;
|
||||
const updatedItems = [...editedSnapshot.items];
|
||||
const item = { ...updatedItems[itemIdx] };
|
||||
item.base_price = newPrice;
|
||||
|
||||
const moduleTotal = (item.selected_modules || []).reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||
item.item_total = newPrice + moduleTotal;
|
||||
|
||||
updatedItems[itemIdx] = item;
|
||||
recalculateSnapshot(updatedItems);
|
||||
};
|
||||
|
||||
const updateModulePriceOrQty = (itemIdx: number, modIdx: number, field: 'price' | 'quantity', value: number) => {
|
||||
if (!editedSnapshot) return;
|
||||
const updatedItems = [...editedSnapshot.items];
|
||||
const item = { ...updatedItems[itemIdx] };
|
||||
const updatedModules = [...(item.selected_modules || [])];
|
||||
const mod = { ...updatedModules[modIdx] };
|
||||
|
||||
if (field === 'price') {
|
||||
mod.price = value;
|
||||
} else {
|
||||
mod.quantity = value;
|
||||
}
|
||||
mod.total_price = mod.price * (mod.quantity || 1);
|
||||
updatedModules[modIdx] = mod;
|
||||
|
||||
item.selected_modules = updatedModules;
|
||||
const moduleTotal = updatedModules.reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||
item.item_total = (item.base_price || 0) + moduleTotal;
|
||||
|
||||
updatedItems[itemIdx] = item;
|
||||
recalculateSnapshot(updatedItems);
|
||||
};
|
||||
|
||||
const recalculateSnapshot = (updatedItems: any[]) => {
|
||||
if (!editedSnapshot) return;
|
||||
const taxRate = editedSnapshot.tax_rate ?? 19;
|
||||
const subtotal = updatedItems.reduce((acc: number, item: any) => acc + (item.item_total || 0), 0);
|
||||
const taxAmount = Math.round(subtotal * (taxRate / 100) * 100) / 100;
|
||||
const total = Math.round((subtotal + taxAmount) * 100) / 100;
|
||||
|
||||
setEditedSnapshot({
|
||||
...editedSnapshot,
|
||||
items: updatedItems,
|
||||
subtotal,
|
||||
tax_amount: taxAmount,
|
||||
total
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSnapshot = async () => {
|
||||
if (!editedSnapshot || !selectedOrder) return;
|
||||
setIsSavingSnapshot(true);
|
||||
try {
|
||||
const updated = await updateOrderPayload(selectedOrder.id, {
|
||||
orderSnapshot: editedSnapshot
|
||||
});
|
||||
setOrders(prev => prev.map(o => o.id === selectedOrder.id ? { ...o, order_data: editedSnapshot, total_price: editedSnapshot.total } : o));
|
||||
setSelectedOrder({ ...selectedOrder, order_data: editedSnapshot, total_price: editedSnapshot.total });
|
||||
setIsEditingSnapshot(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Speichern der Änderungen: " + error.message);
|
||||
} finally {
|
||||
setIsSavingSnapshot(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Badge-Counter berechnen
|
||||
const countAll = orders.length;
|
||||
const countOffen = orders.filter(o => ["pending", "pending_approval", "in_review"].includes(o.status)).length;
|
||||
const countApproved = orders.filter(o => o.status === "approved").length;
|
||||
const countActive = orders.filter(o => ["active", "completed"].includes(o.status)).length;
|
||||
const countRejected = orders.filter(o => ["rejected", "cancelled"].includes(o.status)).length;
|
||||
|
||||
const displaySnapshot = editedSnapshot || selectedOrder?.order_data || {};
|
||||
const displayItems = displaySnapshot.items || [];
|
||||
|
||||
useEffect(() => {
|
||||
setOrders(initialOrders);
|
||||
}, [initialOrders]);
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
|
||||
if (newStatus === 'rejected') {
|
||||
const target = orders.find(o => o.id === orderId);
|
||||
if (target) {
|
||||
setRejectingOrder(target);
|
||||
setRejectionReason("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUpdatingId(orderId);
|
||||
try {
|
||||
await updateOrderStatus(orderId, newStatus);
|
||||
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
|
||||
if (selectedOrder?.id === orderId) {
|
||||
setSelectedOrder((prev: any) => prev ? { ...prev, status: newStatus } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Ändern des Status.");
|
||||
@@ -58,83 +208,142 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
const orderNum = (order.order_number || "").toLowerCase();
|
||||
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||
|
||||
const matchesSearch =
|
||||
orderNum.includes(search.toLowerCase()) ||
|
||||
company.includes(search.toLowerCase()) ||
|
||||
contact.includes(search.toLowerCase());
|
||||
const handleConfirmReject = async () => {
|
||||
if (!rejectingOrder) return;
|
||||
setIsSubmittingReject(true);
|
||||
try {
|
||||
await rejectOrder(rejectingOrder.id, rejectionReason || "Vom Administrator abgelehnt.");
|
||||
setOrders(prev => prev.map(o => o.id === rejectingOrder.id ? { ...o, status: 'rejected' } : o));
|
||||
if (selectedOrder?.id === rejectingOrder.id) {
|
||||
setSelectedOrder((prev: any) => prev ? { ...prev, status: 'rejected' } : null);
|
||||
}
|
||||
setRejectingOrder(null);
|
||||
setRejectionReason("");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Ablehnen der Anfrage.");
|
||||
} finally {
|
||||
setIsSubmittingReject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
const filteredAndSortedOrders = orders
|
||||
.filter((order) => {
|
||||
const orderNum = (order.order_number || "").toLowerCase();
|
||||
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||
|
||||
const matchesSearch =
|
||||
orderNum.includes(search.toLowerCase()) ||
|
||||
company.includes(search.toLowerCase()) ||
|
||||
contact.includes(search.toLowerCase());
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
let matchesStatus = false;
|
||||
if (statusFilter === "all") matchesStatus = true;
|
||||
else if (statusFilter === "offen") matchesStatus = ["pending", "pending_approval", "in_review"].includes(order.status);
|
||||
else if (statusFilter === "approved") matchesStatus = order.status === "approved";
|
||||
else if (statusFilter === "active") matchesStatus = ["active", "completed"].includes(order.status);
|
||||
else if (statusFilter === "rejected") matchesStatus = ["rejected", "cancelled"].includes(order.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'date_desc') return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
if (sortBy === 'date_asc') return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
if (sortBy === 'price_desc') return (b.total_price || 0) - (a.total_price || 0);
|
||||
if (sortBy === 'price_asc') return (a.total_price || 0) - (b.total_price || 0);
|
||||
if (sortBy === 'order_num') return (a.order_number || a.id).localeCompare(b.order_number || b.id);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filterleiste */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||
<div className="relative w-full md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Bestellung, Firma oder Name suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
{/* Filter- & Sortierleiste */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Anfragenr., Firma oder Name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-white/10 bg-slate-950/40 text-slate-200 gap-2 shrink-0">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 text-primary" />
|
||||
<span>
|
||||
{sortBy === 'date_desc' && 'Datum (Neueste zuerst)'}
|
||||
{sortBy === 'date_asc' && 'Datum (Älteste zuerst)'}
|
||||
{sortBy === 'price_desc' && 'Betrag (Höchster zuerst)'}
|
||||
{sortBy === 'price_asc' && 'Betrag (Niedrigster zuerst)'}
|
||||
{sortBy === 'order_num' && 'Anfragenr.'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white">
|
||||
<DropdownMenuItem onClick={() => setSortBy('date_desc')} className="text-xs cursor-pointer">
|
||||
Datum (Neueste zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('date_asc')} className="text-xs cursor-pointer">
|
||||
Datum (Älteste zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('price_desc')} className="text-xs cursor-pointer">
|
||||
Betrag (Höchster zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('price_asc')} className="text-xs cursor-pointer">
|
||||
Betrag (Niedrigster zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('order_num')} className="text-xs cursor-pointer">
|
||||
Anfragenr.
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant={statusFilter === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className="text-xs"
|
||||
className="text-xs gap-1.5"
|
||||
>
|
||||
Alle
|
||||
Alle <Badge variant="secondary" className="bg-white/10 text-white border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countAll}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "pending" ? "default" : "outline"}
|
||||
variant={statusFilter === "offen" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("pending")}
|
||||
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
|
||||
onClick={() => setStatusFilter("offen")}
|
||||
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400 gap-1.5"
|
||||
>
|
||||
Eingegangen
|
||||
Offen / in_review <Badge variant="secondary" className="bg-amber-500/20 text-amber-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countOffen}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "approved" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("approved")}
|
||||
className="text-xs border-emerald-500/20 hover:bg-emerald-500/10 text-emerald-400 gap-1.5"
|
||||
>
|
||||
Genehmigt <Badge variant="secondary" className="bg-emerald-500/20 text-emerald-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countApproved}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "active" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
|
||||
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400 gap-1.5"
|
||||
>
|
||||
In Bearbeitung
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "completed" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("completed")}
|
||||
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
|
||||
>
|
||||
Abgeschlossen
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "cancelled" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("cancelled")}
|
||||
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
|
||||
>
|
||||
Storniert
|
||||
Aktiv <Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countActive}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "rejected" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("rejected")}
|
||||
className="text-xs border-rose-500/20 hover:bg-rose-500/10 text-rose-400"
|
||||
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400 gap-1.5"
|
||||
>
|
||||
Abgelehnt
|
||||
Abgelehnt <Badge variant="secondary" className="bg-red-500/20 text-red-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countRejected}</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,31 +353,31 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
|
||||
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
||||
<TableHead className="text-slate-200">Datum</TableHead>
|
||||
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||
<TableHead className="text-slate-200">Auswahl</TableHead>
|
||||
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||
<TableHead className="text-slate-200">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||
<TableHead className="w-[140px] text-slate-200 font-bold">Anfragenr.</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Datum</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Kunde / Firma</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Auswahl / Produkte</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Gesamtbetrag</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-200 font-bold">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.length === 0 ? (
|
||||
{filteredAndSortedOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
|
||||
Keine Bestellungen gefunden.
|
||||
Keine Anfragen gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
filteredAndSortedOrders.map((order) => {
|
||||
const items = order.order_data?.items || [];
|
||||
return (
|
||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
||||
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors group">
|
||||
<TableCell className="font-mono text-xs text-primary font-bold">
|
||||
#{order.order_number || order.id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 text-sm">
|
||||
<TableCell className="text-slate-300 text-sm whitespace-nowrap">
|
||||
{new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
})}
|
||||
@@ -177,33 +386,43 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
<div className="font-semibold text-white text-sm">
|
||||
{order.customer_data?.company_name || 'Privatkunde'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-slate-500" />
|
||||
{order.customer_data?.first_name} {order.customer_data?.last_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item: any) => (
|
||||
<span
|
||||
key={item.product_id}
|
||||
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
|
||||
<TableCell className="max-w-[240px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.slice(0, 2).map((item: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-[11px] text-slate-300 leading-tight"
|
||||
>
|
||||
{item.product_name}
|
||||
<span className="font-semibold text-white block truncate">
|
||||
{item.device_name || 'Kasse'}: {item.product_name}
|
||||
</span>
|
||||
{item.selected_modules?.length > 0 && (
|
||||
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
|
||||
<span className="text-slate-500 block text-[10px] truncate">
|
||||
+ {item.selected_modules.map((m: any) => m.module_name || m.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length > 2 && (
|
||||
<span className="text-[10px] text-primary font-semibold">
|
||||
+ {items.length - 2} weitere Kassen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-bold text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||
<TableCell className="font-bold text-white text-sm whitespace-nowrap">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price || 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={updatingId === order.id}>
|
||||
<button className="focus:outline-none focus:ring-0 active:scale-95 disabled:pointer-events-none transition-transform duration-100">
|
||||
<Badge className={`text-xs px-2 py-0.5 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||
<Badge className={`text-xs px-2.5 py-1 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||
{updatingId === order.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : null}
|
||||
@@ -212,7 +431,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</Badge>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[140px]">
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[170px]">
|
||||
{Object.entries(statusLabel).map(([key, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
@@ -228,9 +447,17 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs">
|
||||
<a href={`/order?id=${order.id}`}>
|
||||
Bearbeiten
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className="h-8 border-white/10 hover:bg-white/10 text-slate-200 text-xs gap-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-primary" /> Details
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||
<a href={`/order?mode=admin_edit&orderId=${order.id}`}>
|
||||
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||
@@ -238,11 +465,6 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
||||
<a href={`/api/admin/orders/${order.id}/download`}>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -252,6 +474,345 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Detail-Modal für vollständige Anfragenübersicht & Freigabe-Workflow */}
|
||||
{selectedOrder && (
|
||||
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto bg-slate-950 border-white/10 text-white p-6 space-y-6">
|
||||
<DialogHeader className="border-b border-white/10 pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Anfragenr. #{selectedOrder.order_number || selectedOrder.id.slice(0, 8)}
|
||||
</DialogTitle>
|
||||
<Badge className={`text-xs px-2.5 py-0.5 rounded ${statusClass[selectedOrder.status]}`}>
|
||||
{statusLabel[selectedOrder.status] ?? selectedOrder.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="text-xs text-slate-400 mt-1 flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||
Erstellt am {new Date(selectedOrder.created_at).toLocaleString('de-DE')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{isEditingSnapshot ? "Vorschau Summe" : "Gesamtsumme"}
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-primary">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
isEditingSnapshot ? (editedSnapshot?.total || 0) : (selectedOrder.total_price || 0)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Kunden & Stammdaten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
Kunden- & Firmendaten
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{selectedOrder.customer_data?.company_name || 'Keine Firma angegeben'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-slate-500" />
|
||||
{selectedOrder.customer_data?.first_name} {selectedOrder.customer_data?.last_name}
|
||||
</div>
|
||||
{selectedOrder.customer_data?.email && (
|
||||
<div className="text-xs text-slate-300 flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{selectedOrder.customer_data.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
|
||||
<MapPin className="w-4 h-4 text-primary" />
|
||||
Adresse & Standort
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 space-y-1">
|
||||
<div>{selectedOrder.customer_data?.street || 'Keine Straße'}</div>
|
||||
<div>
|
||||
{selectedOrder.customer_data?.zip} {selectedOrder.customer_data?.city}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geräte & Produktauswahl (Nach Kassen / Boxen gruppiert) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
Konfigurierte Kassen ({displayItems.length})
|
||||
</div>
|
||||
{selectedOrder.status === 'in_review' && !isEditingSnapshot && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditingSnapshot(true)}
|
||||
className="h-7 border-primary/30 text-primary hover:bg-primary/10 text-xs font-bold gap-1 rounded-md"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" /> Preise anpassen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingSnapshot && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between bg-amber-500/10 border border-amber-500/20 p-3 rounded-xl gap-4 animate-pulse">
|
||||
<span className="text-xs text-amber-400 font-semibold">Sie befinden sich im Preiskorrektur-Modus.</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||
setIsEditingSnapshot(false);
|
||||
}}
|
||||
className="h-7 text-xs text-slate-300 hover:text-white"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveSnapshot}
|
||||
disabled={isSavingSnapshot}
|
||||
className="h-7 text-xs bg-amber-600 hover:bg-amber-500 text-white font-bold"
|
||||
>
|
||||
{isSavingSnapshot ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{displayItems.map((item: any, idx: number) => {
|
||||
const devName = item.device_name || `Kasse #${idx + 1}`
|
||||
const licNum = item.license_number
|
||||
const modules = item.selected_modules || []
|
||||
|
||||
return (
|
||||
<div key={idx} className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3 relative overflow-hidden">
|
||||
{/* Box Header für die Kasse */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-2.5 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
||||
{devName}
|
||||
</span>
|
||||
{licNum && (
|
||||
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
||||
{licNum}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
|
||||
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Inhalt der Kasse: Hauptprodukt + Preis */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-white">{item.product_name}</span>
|
||||
{isEditingSnapshot ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`price-${idx}`} className="text-[10px] text-slate-400 font-sans">Preis (€):</Label>
|
||||
<Input
|
||||
id={`price-${idx}`}
|
||||
type="number"
|
||||
value={item.base_price || 0}
|
||||
onChange={(e) => updateItemBasePrice(idx, parseFloat(e.target.value) || 0)}
|
||||
className="w-20 h-7 bg-slate-950 border-white/10 text-white text-xs px-2 font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
(item.price || item.base_price) && (
|
||||
<span className="text-xs font-bold text-slate-200 font-mono">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.price || item.base_price)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module dieser Kasse */}
|
||||
{modules.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-white/5">
|
||||
<div className="text-[11px] font-semibold text-slate-400 flex items-center gap-1">
|
||||
<Layers className="w-3 h-3 text-slate-500" />
|
||||
Enthaltene Module ({modules.length}):
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{modules.map((mod: any, mIdx: number) => (
|
||||
<div key={mIdx} className="p-2 rounded-lg bg-slate-950/60 border border-white/5 text-xs flex items-center justify-between gap-3">
|
||||
<span className="text-slate-300 font-medium truncate">
|
||||
+ {mod.module_name || mod.name} {!isEditingSnapshot && mod.quantity > 1 ? `(${mod.quantity}x)` : ''}
|
||||
</span>
|
||||
{isEditingSnapshot ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
title="Einzelpreis"
|
||||
value={mod.price || 0}
|
||||
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'price', parseFloat(e.target.value) || 0)}
|
||||
className="w-14 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 font-sans">x</span>
|
||||
<Input
|
||||
type="number"
|
||||
title="Menge"
|
||||
value={mod.quantity || 1}
|
||||
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'quantity', parseInt(e.target.value) || 1)}
|
||||
className="w-10 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
mod.price && (
|
||||
<span className="text-slate-400 font-mono text-[11px] shrink-0">
|
||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * (mod.quantity || 1))}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Freigabe & Aktionen */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||
Freigabe-Workflow & Verwalter-Aktionen
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'in_review')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white text-xs gap-1.5"
|
||||
>
|
||||
In Prüfung versetzen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'approved')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white text-xs gap-1.5 font-bold"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Anfrage Freigeben
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'active')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs gap-1.5 font-bold"
|
||||
>
|
||||
Sofort Aktivieren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'rejected')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="text-xs gap-1.5"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" /> Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-white/10 pt-4 flex flex-col sm:flex-row justify-between items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedOrder(null)}
|
||||
className="text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||
<a href={`/order?mode=admin_edit&orderId=${selectedOrder.id}`}>
|
||||
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||
<a href={`/api/admin/orders/${selectedOrder.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Vorschau
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
||||
<a href={`/api/admin/orders/${selectedOrder.id}/download`}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> PDF Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Dialog für Ablehnungsbegründung */}
|
||||
{rejectingOrder && (
|
||||
<Dialog open={!!rejectingOrder} onOpenChange={(open) => !open && setRejectingOrder(null)}>
|
||||
<DialogContent className="max-w-md bg-slate-950 border-white/10 text-white p-6 space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold text-red-400 flex items-center gap-2">
|
||||
<XCircle className="w-5 h-5" />
|
||||
Anfrage Ablehnen
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-slate-400">
|
||||
Bitte geben Sie einen Grund für die Ablehnung der Anfrage #{rejectingOrder.order_number || rejectingOrder.id.slice(0, 8)} an.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-300">Begründung für den Partner</Label>
|
||||
<Input
|
||||
placeholder="z.B. Ungültiges Kundenprofil / Modul-Konflikt..."
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="bg-slate-900 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRejectingOrder(null)}
|
||||
disabled={isSubmittingReject}
|
||||
className="text-xs text-slate-400"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleConfirmReject}
|
||||
disabled={isSubmittingReject}
|
||||
className="text-xs gap-1 font-bold"
|
||||
>
|
||||
{isSubmittingReject ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
|
||||
Anfrage definitiv ablehnen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(true)
|
||||
const [showKauf, setShowKauf] = useState(true)
|
||||
const [showAbo, setShowAbo] = useState(true)
|
||||
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
|
||||
@@ -202,7 +202,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
||||
size="sm"
|
||||
onClick={toggleGroupByCategory}
|
||||
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
||||
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
||||
isGroupedByCategory ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 hover:text-white dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700' : ''
|
||||
}`}
|
||||
>
|
||||
<ArrowUpDown className="w-4 h-4 mr-2" />
|
||||
|
||||
469
shop/components/customer-accordion-list.tsx
Normal file
469
shop/components/customer-accordion-list.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import type { EndCustomerWithDevices, FlattenedDevice } from '@/lib/types'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Building2,
|
||||
Edit2,
|
||||
Sparkles,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
Monitor,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Package,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
in_review: 'In Prüfung',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
in_review: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
cancelled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
interface CustomerAccordionListProps {
|
||||
customers: EndCustomerWithDevices[]
|
||||
}
|
||||
|
||||
// ── Einzelne Kassen-Karte ─────────────────────────────────────────────────────
|
||||
function DeviceCard({
|
||||
device,
|
||||
customerId,
|
||||
}: {
|
||||
device: FlattenedDevice
|
||||
customerId: string
|
||||
}) {
|
||||
// Alle Produkt-Chips + Modul-Chips aus den Items
|
||||
const chips: string[] = []
|
||||
for (const item of device.items) {
|
||||
chips.push(item.product_name)
|
||||
for (const mod of item.selected_modules || []) {
|
||||
chips.push(mod.module_name)
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeUrl =
|
||||
`/order?mode=upgrade` +
|
||||
`&orderId=${device.orderId}` +
|
||||
`&customer_id=${customerId}` +
|
||||
`&device_id=${encodeURIComponent(device.deviceId)}`
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden">
|
||||
{/* Kassen-Info links */}
|
||||
<div className="space-y-2.5 min-w-[200px] flex-1">
|
||||
{/* Kassen-Name + Lizenznummer daneben + Status */}
|
||||
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
||||
{device.deviceName}
|
||||
</span>
|
||||
|
||||
{device.items[0]?.license_number && (
|
||||
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
||||
{device.items[0].license_number}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
className={`text-xs ${statusClass[device.orderStatus] ?? 'bg-slate-500/20 text-slate-300'}`}
|
||||
>
|
||||
{statusLabel[device.orderStatus] ?? device.orderStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Bestellnummer + Datum */}
|
||||
<p className="text-xs text-slate-400 font-mono">
|
||||
#{device.orderNumber} • Erstellt am{' '}
|
||||
{new Date(device.createdAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Produkt + Modul Chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{chips.map((chip, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preis + Aktions-Buttons rechts */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-between md:justify-end border-t md:border-t-0 pt-3 md:pt-0 border-white/5 shrink-0">
|
||||
{/* Preis */}
|
||||
<div className="text-left md:text-right mr-2">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Gesamtwert</p>
|
||||
<p className="font-bold text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(device.totalPrice)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update / Upgrade Button für Kasse */}
|
||||
<Link href={upgradeUrl}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-sky-500/20 hover:bg-sky-500/30 text-sky-300 border border-sky-500/40 text-xs font-bold gap-1.5 shadow-[0_0_12px_rgba(56,189,248,0.2)]"
|
||||
title="Kasse updaten oder upgraden (Erstelldatum wird für den Update-Rabatt genutzt)"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-sky-400" />
|
||||
Update / Upgrade
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* PDF Download */}
|
||||
{device.pdfUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="border-white/10 hover:bg-white/10 text-xs text-slate-300"
|
||||
>
|
||||
<a
|
||||
href={`/api/orders/${device.orderId}/download`}
|
||||
download
|
||||
title="Bestellbestätigung PDF herunterladen"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<Link href={`/order/success?id=${device.orderId}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white text-xs p-2"
|
||||
title="Details anzeigen"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Haupt-Komponente ──────────────────────────────────────────────────────────
|
||||
export function CustomerAccordionList({ customers }: CustomerAccordionListProps) {
|
||||
const [openCustomerIds, setOpenCustomerIds] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {}
|
||||
customers.forEach((c) => {
|
||||
initial[c.id] = true
|
||||
})
|
||||
return initial
|
||||
})
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 5
|
||||
|
||||
const toggleCustomer = (id: string) => {
|
||||
setOpenCustomerIds((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}))
|
||||
}
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return customers
|
||||
|
||||
return customers.filter((c) => {
|
||||
return (
|
||||
c.company_name.toLowerCase().includes(term) ||
|
||||
(c.first_name && c.first_name.toLowerCase().includes(term)) ||
|
||||
(c.last_name && c.last_name.toLowerCase().includes(term)) ||
|
||||
(c.city && c.city.toLowerCase().includes(term)) ||
|
||||
(c.email && c.email.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
}, [customers, searchTerm])
|
||||
|
||||
const totalPages = Math.ceil(filteredCustomers.length / pageSize) || 1
|
||||
|
||||
const paginatedCustomers = useMemo(() => {
|
||||
const from = (currentPage - 1) * pageSize
|
||||
return filteredCustomers.slice(from, from + pageSize)
|
||||
}, [filteredCustomers, currentPage, pageSize])
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
if (customers.length === 0) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Building2 className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
|
||||
<Link href="/my-customers/new">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Suchfeld & Info */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Kunden suchen (Firma, Name, Stadt)..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-9 bg-slate-900/50 border-white/10 text-white placeholder:text-slate-500 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 bg-slate-900/50 px-3.5 py-2 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
|
||||
<span>Kunden:</span>
|
||||
<span className="text-white font-bold">{filteredCustomers.length}</span>
|
||||
{searchTerm && <span className="text-slate-500">(gefiltert aus {customers.length})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keine Suchergebnisse */}
|
||||
{filteredCustomers.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Search className="w-10 h-10 text-slate-600" />
|
||||
<p className="text-slate-300 font-medium">Keine Kunden für "{searchTerm}" gefunden.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Akkordeon-Liste */}
|
||||
{paginatedCustomers.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{paginatedCustomers.map((customer) => {
|
||||
const isOpen = !!openCustomerIds[customer.id]
|
||||
const devices = customer.devices || []
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={customer.id}
|
||||
className="glass-dark border-white/10 overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Akkordeon-Header */}
|
||||
<div
|
||||
onClick={() => toggleCustomer(customer.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggleCustomer(customer.id)
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isOpen}
|
||||
className="p-5 flex items-center justify-between cursor-pointer hover:bg-white/[0.02] transition-colors select-none gap-4 flex-wrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-[240px]">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-bold">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-white">{customer.company_name}</h3>
|
||||
{customer.is_anonymized && (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-xs gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Anonymisiert
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.first_name, customer.last_name].filter(Boolean).join(' ') ||
|
||||
'Kein Ansprechpartner'}
|
||||
{customer.city ? ` • ${customer.city}` : ''}
|
||||
{customer.email ? ` • ${customer.email}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
{/* Kassen-Anzahl Badge */}
|
||||
<Badge variant="outline" className="border-white/10 text-slate-300 bg-white/5">
|
||||
<Package className="w-3.5 h-3.5 mr-1 text-primary" />
|
||||
{devices.length} {devices.length === 1 ? 'Kasse' : 'Kassen'}
|
||||
</Badge>
|
||||
|
||||
{!customer.is_anonymized && (
|
||||
<>
|
||||
<Link
|
||||
href={`/order?customer_id=${customer.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/40 gap-1.5 text-xs font-bold shadow-[0_0_10px_rgba(59,130,246,0.2)]"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" /> Kunde updaten / bestellen
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/my-customers/${customer.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white hover:bg-white/10 gap-1 text-xs"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white p-1"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausgeklappter Bereich mit Kassen */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden border-t border-white/5 bg-slate-950/40"
|
||||
>
|
||||
<div className="p-5 space-y-3">
|
||||
{devices.length === 0 ? (
|
||||
<div className="text-slate-500 text-sm py-4 text-center">
|
||||
Keine Kassen oder Bestellungen für diesen Kunden vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-wider text-slate-400 font-semibold mb-2">
|
||||
Zugeordnete Kassen ({devices.length})
|
||||
</p>
|
||||
{devices.map((device, idx) => (
|
||||
<DeviceCard
|
||||
key={`${device.orderId}-${device.deviceId}-${idx}`}
|
||||
device={device}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginierung (5 Kunden pro Seite) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite <span className="text-white font-bold">{currentPage}</span> von{' '}
|
||||
<span className="text-white font-bold">{totalPages}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 px-2 text-slate-400 hover:text-white"
|
||||
title="Erste Seite"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" /> Vorherige
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl h-8 px-3"
|
||||
>
|
||||
Nächste <ChevronRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 px-2 text-slate-400 hover:text-white"
|
||||
title="Letzte Seite"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
// Inaktivitäts-Schwellenwert (10 Minuten = 600.000 ms)
|
||||
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
// Countdown-Dauer vor automatischem Logout (10 Sekunden)
|
||||
const COUNTDOWN_SECONDS = 10;
|
||||
|
||||
export function InactivityTracker() {
|
||||
const router = useRouter();
|
||||
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
|
||||
|
||||
const lastActivityRef = useRef<number>(Date.now());
|
||||
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Supabase URL & Anon Key auslesen (für Browser-Client)
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
// Browser Client inline erstellen
|
||||
const supabase = createBrowserClient(
|
||||
resolveSupabaseUrl(supabaseUrl)!,
|
||||
supabaseAnonKey!,
|
||||
@@ -24,45 +45,216 @@ export function InactivityTracker() {
|
||||
}
|
||||
);
|
||||
|
||||
// Cookie-Hilfsfunktionen für Aktivitätssynchronisation mit dem Server
|
||||
const updateLastActivityCookie = useCallback(() => {
|
||||
const now = Date.now();
|
||||
lastActivityRef.current = now;
|
||||
document.cookie = `webshop-last-activity=${now}; path=/; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
const getLastActivityFromCookie = useCallback((): number => {
|
||||
const match = document.cookie.match(/(?:^|; )webshop-last-activity=([^;]*)/);
|
||||
if (match && match[1]) {
|
||||
const val = parseInt(match[1], 10);
|
||||
if (!isNaN(val)) return val;
|
||||
}
|
||||
return lastActivityRef.current;
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async (reason = "inactivity") => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
setShowModal(false);
|
||||
document.cookie = "webshop-last-activity=; path=/; max-age=0";
|
||||
try {
|
||||
await signOut();
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Server-Signout:", e);
|
||||
}
|
||||
await supabase.auth.signOut();
|
||||
router.push(`/auth/login?message=${reason}`);
|
||||
router.refresh();
|
||||
}, [supabase, router]);
|
||||
|
||||
// Session-Prüfung & Inaktivitäts-Überwachung
|
||||
useEffect(() => {
|
||||
// Regelmäßige Überprüfung der Session-Gültigkeit (z.B. wegen Login auf anderem Browser/Gerät)
|
||||
// 1. Session prüfen
|
||||
const checkSession = async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
setIsLoggedIn(!!session);
|
||||
};
|
||||
checkSession();
|
||||
|
||||
// 2. Regelmäßige Intervall-Prüfung (alle 5s)
|
||||
const interval = setInterval(async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return; // Nicht eingeloggt, keine Prüfung nötig
|
||||
if (!session) {
|
||||
setIsLoggedIn(false);
|
||||
return;
|
||||
}
|
||||
setIsLoggedIn(true);
|
||||
|
||||
// Prüfen, ob die Session auf dem Server noch gültig ist (z.B. wegen Login auf anderem Browser)
|
||||
// Prüfen, ob Session auf Server noch gültig ist
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
if (error) {
|
||||
// Nur bei echten Auth-Fehlern (z.B. ungültiges Token / Session gelöscht) abmelden.
|
||||
// Netzwerkfehler wie "Failed to fetch" haben keinen HTTP-Status (error.status ist undefined).
|
||||
const isAuthError = error.status === 400 || error.status === 401 || error.status === 403;
|
||||
if (isAuthError) {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
|
||||
}
|
||||
await supabase.auth.signOut();
|
||||
router.push("/auth/login?message=concurrent");
|
||||
router.refresh();
|
||||
handleLogout("concurrent");
|
||||
return;
|
||||
}
|
||||
} else if (!user) {
|
||||
// Keine Fehlermeldung, aber auch kein User-Objekt (Sitzung abgelaufen/gelöscht)
|
||||
try {
|
||||
await signOut();
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Server-Signout nach Session-Verlust:", e);
|
||||
}
|
||||
await supabase.auth.signOut();
|
||||
router.push("/auth/login?message=concurrent");
|
||||
router.refresh();
|
||||
handleLogout("concurrent");
|
||||
return;
|
||||
}
|
||||
}, 5000); // Prüfung alle 5 Sekunden
|
||||
|
||||
// Inaktivitätszeitraum (Client + Cookie-Synchronisation) prüfen
|
||||
const lastActive = getLastActivityFromCookie();
|
||||
const now = Date.now();
|
||||
if (!showModal && now - lastActive >= INACTIVITY_TIMEOUT_MS) {
|
||||
setShowModal(true);
|
||||
setCountdown(COUNTDOWN_SECONDS);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [supabase, router]);
|
||||
}, [supabase, handleLogout, showModal, getLastActivityFromCookie]);
|
||||
|
||||
return null;
|
||||
// Aktivitätsevents registrieren
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
let lastEventTime = 0;
|
||||
const handleUserActivity = () => {
|
||||
const now = Date.now();
|
||||
// Throttling: max. 1x pro Sekunde aktualisieren
|
||||
if (now - lastEventTime > 1000) {
|
||||
lastEventTime = now;
|
||||
// Wenn Modal bereits offen ist, Aktivität nicht im Hintergrund zurücksetzen
|
||||
if (!showModal) {
|
||||
updateLastActivityCookie();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const events = ["mousemove", "keydown", "click", "scroll", "touchstart"];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, handleUserActivity, { passive: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, handleUserActivity);
|
||||
});
|
||||
};
|
||||
}, [isLoggedIn, showModal, updateLastActivityCookie]);
|
||||
|
||||
// Countdown-Timer verwalten, wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (!showModal) {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(countdownIntervalRef.current!);
|
||||
handleLogout("inactivity");
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [showModal, handleLogout]);
|
||||
|
||||
// Inaktivität zurücksetzen / Modal schließen
|
||||
const handleStayLoggedIn = () => {
|
||||
updateLastActivityCookie();
|
||||
setShowModal(false);
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoggedIn) return null;
|
||||
|
||||
// Werte für den radialen SVG-Timer (Kreisumfang)
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (countdown / COUNTDOWN_SECONDS) * circumference;
|
||||
|
||||
return (
|
||||
<Dialog open={showModal} onOpenChange={(open) => { if (!open) handleStayLoggedIn(); }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md text-center flex flex-col items-center gap-6 p-8">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center mb-2">
|
||||
<Clock className="w-6 h-6 animate-pulse" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl font-bold text-foreground">
|
||||
Noch da?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground text-sm max-w-xs mt-1">
|
||||
Sie sind seit längerem inaktiv. Aus Sicherheitsgründen werden Sie gleich abgemeldet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Radialer SVG-Timer */}
|
||||
<div className="relative flex items-center justify-center my-2">
|
||||
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
|
||||
{/* Hintergrundkreis */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
className="stroke-muted/20"
|
||||
strokeWidth="8"
|
||||
fill="transparent"
|
||||
/>
|
||||
{/* Countdown-Fortschrittskreis */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
className="stroke-amber-500 transition-all duration-1000 ease-linear"
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
/>
|
||||
</svg>
|
||||
{/* Numerische Restzeit in Sekunden */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-extrabold text-foreground tracking-tight">
|
||||
{countdown}s
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mt-0.5">
|
||||
Verbleibend
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="w-full sm:justify-center">
|
||||
<Button
|
||||
onClick={handleStayLoggedIn}
|
||||
className="w-full sm:w-auto px-8 bg-amber-500 hover:bg-amber-600 text-black font-semibold shadow-lg shadow-amber-500/20"
|
||||
>
|
||||
Ich bin noch da
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,27 @@ const styles = StyleSheet.create({
|
||||
}
|
||||
});
|
||||
|
||||
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
export const stripEmojis = (text?: string | null): string => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]/gu, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
export const InvoicePDF = ({
|
||||
order,
|
||||
orderSnapshot,
|
||||
customer,
|
||||
partnerCompanyName,
|
||||
partnerUserName,
|
||||
partnerUserEmail,
|
||||
}: any) => {
|
||||
const items = orderSnapshot?.items ?? [];
|
||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
||||
const formattedOrderNumber = stripEmojis((order.order_number || order.id || '').replace(/^BE-/, 'AE-'));
|
||||
const isUpgrade = !!orderSnapshot?.last_license_date;
|
||||
|
||||
let oneTimeNet = 0;
|
||||
let monthlyNet = 0;
|
||||
@@ -108,11 +125,10 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
} 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;
|
||||
});
|
||||
});
|
||||
@@ -128,13 +144,17 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
|
||||
const groupedItems: { [key: string]: any[] } = {};
|
||||
items.forEach((item: any) => {
|
||||
const devName = item.device_name || 'Kasse 1';
|
||||
const devName = stripEmojis(item.device_name || 'Kasse 1');
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = [];
|
||||
}
|
||||
groupedItems[devName].push(item);
|
||||
});
|
||||
|
||||
const displayPartnerName = stripEmojis(partnerCompanyName || order?.partner_company_name);
|
||||
const displayUserName = stripEmojis(partnerUserName || order?.partner_user_name);
|
||||
const displayUserEmail = stripEmojis(partnerUserEmail || order?.partner_user_email);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
@@ -152,23 +172,34 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>
|
||||
{isSubscription ? 'Anfragebestätigung: Software-Abonnement' : 'Anfragebestätigung: Einmalige Lizenzen'}
|
||||
{isUpgrade ? 'Erweiterungsangebot' : 'Anfragebestätigung'}: #{formattedOrderNumber}
|
||||
</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Kunde</Text>
|
||||
<Text>{customer.company_name}</Text>
|
||||
<Text>{customer.first_name} {customer.last_name}</Text>
|
||||
<Text>{customer.address}</Text>
|
||||
<Text>{customer.zip_code} {customer.city}</Text>
|
||||
<Text>USt-IdNr: {customer.vat_id}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 15 }}>
|
||||
<View style={{ width: '48%' }}>
|
||||
<Text style={styles.label}>Endkunde</Text>
|
||||
<Text style={{ fontWeight: 'bold' }}>{stripEmojis(customer?.company_name)}</Text>
|
||||
{(customer?.first_name || customer?.last_name) ? <Text>{stripEmojis(`${customer?.first_name || ''} ${customer?.last_name || ''}`)}</Text> : null}
|
||||
{customer?.address ? <Text>{stripEmojis(customer.address)}</Text> : null}
|
||||
{(customer?.zip_code || customer?.city) ? <Text>{stripEmojis(`${customer?.zip_code || ''} ${customer?.city || ''}`)}</Text> : null}
|
||||
{customer?.vat_id ? <Text>USt-IdNr: {stripEmojis(customer.vat_id)}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Bestelldetails</Text>
|
||||
<Text>Bestellnummer: {order.order_number || order.id}</Text>
|
||||
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
|
||||
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||
<View style={{ width: '48%' }}>
|
||||
<Text style={styles.label}>Bestelldetails</Text>
|
||||
<Text>Bestellnummer: #{formattedOrderNumber}</Text>
|
||||
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
|
||||
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
|
||||
|
||||
{(displayPartnerName || displayUserName || displayUserEmail) && (
|
||||
<View style={{ marginTop: 6, paddingTop: 6, borderTopWidth: 1, borderColor: '#eee' }}>
|
||||
<Text style={styles.label}>Erstellt durch Partner / User</Text>
|
||||
{displayPartnerName ? <Text style={{ fontWeight: 'bold' }}>Partner: {displayPartnerName}</Text> : null}
|
||||
{displayUserName ? <Text>Erstellt von: {displayUserName}</Text> : null}
|
||||
{displayUserEmail ? <Text>E-Mail: {displayUserEmail}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 10, marginBottom: 15 }}>
|
||||
@@ -176,14 +207,14 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<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' }}>
|
||||
Gerät: {deviceName}
|
||||
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `${isUpgrade ? 'Erweiterung für Gerät' : 'Kasse'}: ${deviceName}`}
|
||||
</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 style={{ fontSize: 9, fontWeight: 'bold', color: '#344155' }}>
|
||||
{stripEmojis(item.product_name)} ({stripEmojis(item.category_name)})
|
||||
</Text>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#0f172a' }}>
|
||||
{formattedPrice(item.base_price)} {item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
@@ -195,7 +226,7 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
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})` : ''}
|
||||
+ {stripEmojis(mod.module_name)} {qty > 1 ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#64748b' }}>
|
||||
+{formattedPrice(price)} mtl.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { signIn } from "@/lib/actions/auth";
|
||||
import { signIn, verifyDevice2FA, resend2FACode } from "@/lib/actions/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,7 +14,19 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Ban } from "lucide-react";
|
||||
|
||||
function getDeviceHash(): string {
|
||||
const key = "caspos-device-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@@ -28,32 +40,61 @@ export function LoginForm({
|
||||
const [errorParam, setErrorParam] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// 2FA state
|
||||
const [show2FA, setShow2FA] = useState(false);
|
||||
const [otpDigits, setOtpDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setMessageParam(params.get('message'));
|
||||
setErrorParam(params.get('error'));
|
||||
}, []);
|
||||
|
||||
// Resend cooldown timer
|
||||
useEffect(() => {
|
||||
if (resendCooldown <= 0) return;
|
||||
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resendCooldown]);
|
||||
|
||||
const navigateAfterLogin = useCallback((role: string) => {
|
||||
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
||||
if (nextParam) {
|
||||
router.push(nextParam);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
||||
|
||||
try {
|
||||
const res = await signIn(email, password);
|
||||
const deviceHash = getDeviceHash();
|
||||
const res = await signIn(email, password, deviceHash);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.error || "Ein Fehler ist aufgetreten.");
|
||||
return;
|
||||
}
|
||||
if (nextParam) {
|
||||
router.push(nextParam);
|
||||
} else if (res.role === "admin") {
|
||||
router.push("/admin/einstellungen");
|
||||
} else {
|
||||
router.push("/my-customers");
|
||||
|
||||
if (res.requires2FA) {
|
||||
setUserId(res.userId || null);
|
||||
setUserRole(res.role || null);
|
||||
setShow2FA(true);
|
||||
setResendCooldown(60);
|
||||
setOtpDigits(["", "", "", "", "", ""]);
|
||||
setTimeout(() => inputRefs.current[0]?.focus(), 300);
|
||||
return;
|
||||
}
|
||||
|
||||
navigateAfterLogin(res.role || "partner");
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
|
||||
} finally {
|
||||
@@ -61,64 +102,250 @@ export function LoginForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpChange = (index: number, value: string) => {
|
||||
if (!/^\d*$/.test(value)) return;
|
||||
const newDigits = [...otpDigits];
|
||||
newDigits[index] = value.slice(-1);
|
||||
setOtpDigits(newDigits);
|
||||
|
||||
// Auto-advance
|
||||
if (value && index < 5) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
// Auto-submit when all filled
|
||||
if (newDigits.every(d => d !== "") && newDigits.join("").length === 6) {
|
||||
handleVerify2FA(newDigits.join(""));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
if (e.key === "Backspace" && !otpDigits[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpPaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
const paste = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
||||
if (paste.length === 6) {
|
||||
const newDigits = paste.split("");
|
||||
setOtpDigits(newDigits);
|
||||
inputRefs.current[5]?.focus();
|
||||
handleVerify2FA(paste);
|
||||
}
|
||||
};
|
||||
|
||||
const isVerifyingRef = useRef(false);
|
||||
|
||||
const handleVerify2FA = async (code: string) => {
|
||||
if (!userId || isVerifyingRef.current) return;
|
||||
isVerifyingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const deviceHash = getDeviceHash();
|
||||
const res = await verifyDevice2FA(userId, code, deviceHash);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.error || "Ungültiger Code.");
|
||||
setOtpDigits(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
navigateAfterLogin(res.role || userRole || "partner");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Fehler bei der Verifizierung.");
|
||||
setOtpDigits(["", "", "", "", "", ""]);
|
||||
inputRefs.current[0]?.focus();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isVerifyingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!userId || resendCooldown > 0) return;
|
||||
setError(null);
|
||||
const deviceHash = getDeviceHash();
|
||||
const res = await resend2FACode(userId, deviceHash);
|
||||
if (res.success) {
|
||||
setResendCooldown(60);
|
||||
} else {
|
||||
setError(res.error || "Fehler beim erneuten Senden.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Anmelden</CardTitle>
|
||||
<CardDescription>
|
||||
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
<Card className="overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{!show2FA ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -40, filter: "blur(4px)" }}
|
||||
transition={{ duration: 0.35, ease: "easeInOut" }}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Anmelden</CardTitle>
|
||||
<CardDescription>
|
||||
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">E-Mail</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@beispiel.de"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Passwort</Label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
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 flex items-center justify-center gap-2">
|
||||
<Ban className="w-4 h-4 shrink-0" />
|
||||
<span>Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.</span>
|
||||
</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>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="2fa"
|
||||
initial={{ opacity: 0, x: 40, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, x: -40 }}
|
||||
transition={{ duration: 0.35, ease: "easeInOut" }}
|
||||
>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-600 dark:text-blue-400">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
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>
|
||||
</CardContent>
|
||||
<CardTitle className="text-2xl">Sicherheitscode</CardTitle>
|
||||
<CardDescription className="text-sm mt-1">
|
||||
Wir haben einen 6-stelligen Code an<br />
|
||||
<span className="font-semibold text-foreground">{email}</span><br />
|
||||
gesendet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* OTP Input */}
|
||||
<div className="flex justify-center gap-2" onPaste={handleOtpPaste}>
|
||||
{otpDigits.map((digit, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => { inputRefs.current[i] = el; }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleOtpKeyDown(i, e)}
|
||||
className={cn(
|
||||
"h-14 w-11 rounded-xl border-2 bg-muted/50 text-center text-xl font-bold transition-all duration-200",
|
||||
"focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none",
|
||||
"hover:border-muted-foreground/40",
|
||||
digit ? "border-blue-400 bg-blue-50 dark:bg-blue-950/30" : "border-muted-foreground/20"
|
||||
)}
|
||||
id={`otp-${i}`}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-red-500 text-center bg-red-500/10 border border-red-500/20 p-3 rounded-lg font-medium"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={isLoading || otpDigits.some(d => !d)}
|
||||
onClick={() => handleVerify2FA(otpDigits.join(""))}
|
||||
>
|
||||
{isLoading ? "Wird überprüft..." : "Bestätigen"}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Code nicht erhalten?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0}
|
||||
className={cn(
|
||||
"font-medium underline-offset-4 hover:underline transition-colors",
|
||||
resendCooldown > 0
|
||||
? "text-muted-foreground/50 cursor-not-allowed"
|
||||
: "text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
)}
|
||||
>
|
||||
{resendCooldown > 0 ? `Erneut senden (${resendCooldown}s)` : "Erneut senden"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShow2FA(false);
|
||||
setError(null);
|
||||
setOtpDigits(["", "", "", "", "", ""]);
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors text-center"
|
||||
>
|
||||
← Zurück zur Anmeldung
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -156,7 +156,7 @@ export function FetchDataSteps() {
|
||||
</TutorialStep>
|
||||
|
||||
<TutorialStep title="Build in a weekend and scale to millions!">
|
||||
<p>You're ready to launch your product to the world! 🚀</p>
|
||||
<p>You're ready to launch your product to the world!</p>
|
||||
</TutorialStep>
|
||||
</ol>
|
||||
);
|
||||
|
||||
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> = {
|
||||
'995502-00': {
|
||||
licenseKey: '995502-00',
|
||||
status: 'active',
|
||||
product: 'CASPOS',
|
||||
edition: 'GASTRO',
|
||||
version: '4.8.6',
|
||||
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'],
|
||||
},
|
||||
'995501-00': {
|
||||
licenseKey: '995501-00',
|
||||
status: 'expired',
|
||||
product: 'CASPOS Handel',
|
||||
edition: 'Standard',
|
||||
version: '3.12.1',
|
||||
seats: 1,
|
||||
customer: 'Beispiel AG',
|
||||
contact: 'Erika Muster',
|
||||
issuedAt: '2016-03-14',
|
||||
expiresAt: '2017-03-14',
|
||||
maintenanceUntil: '2017-03-14',
|
||||
modules: ['CASPOS Handel'],
|
||||
},
|
||||
'995500-00': {
|
||||
licenseKey: '995500-00',
|
||||
status: 'active',
|
||||
product: 'CASPOS Handel',
|
||||
edition: 'Standard',
|
||||
version: '4.11.2',
|
||||
seats: 2,
|
||||
customer: 'Tech Solutions GmbH & Co. KG',
|
||||
contact: 'Julia Schneider',
|
||||
issuedAt: '2026-01-01',
|
||||
expiresAt: '2027-12-31',
|
||||
maintenanceUntil: '2027-12-31',
|
||||
modules: ['CASPOS Handel'],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── 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 CASPOS-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. 995500-00"
|
||||
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('995502-00')}
|
||||
>
|
||||
995502-00
|
||||
</span>
|
||||
{' · '}
|
||||
<span
|
||||
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
|
||||
onClick={() => setLicenseKey('995501-00')}
|
||||
>
|
||||
995501-00 (abgelaufen)
|
||||
</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>
|
||||
)
|
||||
}
|
||||
145
shop/components/wizard/progress-stepper.tsx
Normal file
145
shop/components/wizard/progress-stepper.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ProgressStepperProps {
|
||||
step: number
|
||||
basketItemsCount: number
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
onStepClick?: (step: number) => void
|
||||
}
|
||||
|
||||
const STEP_LABELS = ['Kunde', 'Modell', 'Software', 'Abschluss']
|
||||
|
||||
export function ProgressStepper({ step, basketItemsCount, orientation = 'horizontal', onStepClick }: ProgressStepperProps) {
|
||||
const progressPercent = ((step - 1) / 3) * 100
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<div className="relative py-2">
|
||||
<div className="flex flex-col gap-6 relative">
|
||||
{/* Static Background Vertical Line */}
|
||||
<div className="absolute left-[19px] top-[20px] bottom-[20px] w-[2px] bg-slate-800 z-0 rounded-full" />
|
||||
|
||||
{/* Animated Active Vertical Line */}
|
||||
<motion.div
|
||||
className="absolute left-[19px] top-[20px] w-[2px] bg-gradient-to-b from-blue-600 via-sky-400 to-cyan-400 z-0 rounded-full shadow-[0_0_12px_rgba(56,189,248,0.8)]"
|
||||
initial={{ height: '0%' }}
|
||||
animate={{ height: `${progressPercent * 0.75}%` }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
/>
|
||||
|
||||
{[1, 2, 3, 4].map(s => {
|
||||
const isDone = step > s
|
||||
const isActive = step === s
|
||||
const isClickable = onStepClick && s < step
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s}
|
||||
onClick={() => isClickable && onStepClick(s)}
|
||||
className={`relative z-10 flex items-center gap-3 ${isClickable ? 'cursor-pointer group' : ''}`}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.1 : 1,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-300 shrink-0 ${
|
||||
isDone
|
||||
? 'bg-sky-500 text-slate-950 shadow-[0_0_15px_rgba(56,189,248,0.6)] border-2 border-sky-400 group-hover:scale-105'
|
||||
: isActive
|
||||
? 'bg-slate-950 border-2 border-sky-400 text-sky-400 shadow-[0_0_20px_rgba(56,189,248,0.7)]'
|
||||
: 'bg-slate-900 border-2 border-slate-800 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isDone ? <Check className="w-5 h-5 stroke-[3]" /> : s}
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={`text-xs font-bold tracking-wide transition-colors duration-300 flex items-center gap-1.5 ${
|
||||
isActive || isDone ? 'text-sky-300' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s - 1]}
|
||||
{s === 3 && basketItemsCount > 0 && (
|
||||
<span className="bg-sky-500 text-slate-950 text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-extrabold shadow-sm">
|
||||
{basketItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isClickable && (
|
||||
<span className="text-[10px] text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Zurück zu Schritt {s}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Calculate progress percentage for active line (Step 1: 0%, Step 2: 33.3%, Step 3: 66.6%, Step 4: 100%)
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto mb-10 px-4">
|
||||
<div className="flex justify-between relative">
|
||||
{/* Static Background Line */}
|
||||
<div className="absolute top-[20px] left-[10%] right-[10%] h-[2px] bg-slate-800 z-0 rounded-full" />
|
||||
|
||||
{/* Animated Active Line (bg-sky-500) */}
|
||||
<motion.div
|
||||
className="absolute top-[20px] left-[10%] h-[2px] bg-gradient-to-r from-blue-600 via-sky-400 to-cyan-400 z-0 rounded-full shadow-[0_0_12px_rgba(56,189,248,0.8)]"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${progressPercent * 0.8}%` }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
/>
|
||||
|
||||
{[1, 2, 3, 4].map(s => {
|
||||
const isDone = step > s
|
||||
const isActive = step === s
|
||||
|
||||
return (
|
||||
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
scale: isActive ? 1.15 : 1,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all duration-300 ${
|
||||
isDone
|
||||
? 'bg-sky-500 text-slate-950 shadow-[0_0_15px_rgba(56,189,248,0.6)] border-2 border-sky-400'
|
||||
: isActive
|
||||
? 'bg-slate-950 border-2 border-sky-400 text-sky-400 shadow-[0_0_20px_rgba(56,189,248,0.7)]'
|
||||
: 'bg-slate-900 border-2 border-slate-800 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isDone ? <Check className="w-5 h-5 stroke-[3]" /> : s}
|
||||
</motion.div>
|
||||
|
||||
<span
|
||||
className={`text-xs font-semibold tracking-wide transition-colors duration-300 flex items-center gap-1.5 ${
|
||||
isActive || isDone ? 'text-sky-300' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s - 1]}
|
||||
{s === 3 && basketItemsCount > 0 && (
|
||||
<span className="bg-sky-500 text-slate-950 text-[10px] w-4 h-4 rounded-full flex items-center justify-center font-extrabold shadow-sm">
|
||||
{basketItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
shop/components/wizard/step-billing.tsx
Normal file
237
shop/components/wizard/step-billing.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
CreditCard,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
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,
|
||||
lastLicenseDate,
|
||||
setLastLicenseDate,
|
||||
prevStep,
|
||||
nextStep,
|
||||
}: StepBillingProps) {
|
||||
const isOneTime = selectedBillingInterval === 'one_time'
|
||||
const isMonthly = selectedBillingInterval === 'monthly'
|
||||
|
||||
return (
|
||||
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col justify-between overflow-hidden relative">
|
||||
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
|
||||
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
|
||||
<CreditCard className="w-4 h-4 text-primary" />
|
||||
Abrechnungsmodell wählen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
|
||||
Wählen Sie zwischen monatlicher Miete (Abonnement) oder einmaligem Softwarekauf.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-xs px-2.5 py-0.5">
|
||||
Schritt 2 von 4
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ─── 2. NO-SCROLL MIDDLE CONTENT (flex-1 flex flex-col justify-center items-center overflow-hidden p-4) ─── */}
|
||||
<div className="flex-1 min-h-0 flex flex-col justify-center items-center overflow-hidden p-4 w-full">
|
||||
<div className="max-w-3xl w-full grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
{/* Option 1: Miete / Abo */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('monthly')}
|
||||
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between select-none ${
|
||||
isMonthly
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_20px_rgba(59,130,246,0.2)] ring-1 ring-primary/50'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/[0.07]'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className={`p-2.5 rounded-lg ${isMonthly ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-white/5 text-slate-400 border border-white/10'}`}>
|
||||
<RefreshCw className={`w-5 h-5 ${isMonthly ? 'animate-spin-slow' : ''}`} />
|
||||
</div>
|
||||
{isMonthly && (
|
||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center text-white shadow-md shadow-primary/30">
|
||||
<Check className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-bold text-white">Miete / Abo</h3>
|
||||
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-blue-500/10 border-blue-500/30 text-blue-400">
|
||||
Empfohlen
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] font-medium text-slate-300">
|
||||
Fortlaufende monatliche Abrechnung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-400 text-[11px] mt-2 leading-relaxed">
|
||||
Maximale Flexibilität: Inklusive sämtlicher Software-Updates, Cloud-Dienste und Support. Jederzeit kündbar oder anpassbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2.5 border-t border-white/10 flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-400">Zahlungsintervall:</span>
|
||||
<span className="text-primary font-semibold">monatlich</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Option 2: Kauf / Einmalkauf (mit integrierter Inline-Erweiterung) */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('one_time')}
|
||||
className={`relative p-5 rounded-xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between select-none ${
|
||||
isOneTime
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_20px_rgba(59,130,246,0.2)] ring-1 ring-primary/50'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20 hover:bg-white/[0.07]'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div className={`p-2.5 rounded-lg ${isOneTime ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-white/5 text-slate-400 border border-white/10'}`}>
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
</div>
|
||||
{isOneTime && (
|
||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center text-white shadow-md shadow-primary/30">
|
||||
<Check className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-bold text-white">Kauf / Einmalkauf</h3>
|
||||
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-slate-500/10 border-slate-500/30 text-slate-300">
|
||||
Klassisch
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] font-medium text-slate-300">
|
||||
Einmalige Lizenzgebühr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-400 text-[11px] mt-2 leading-relaxed">
|
||||
Dauerhafte Lizenzrechte für die gekaufte Version inklusive 12 Monate kostenfreier Updates.
|
||||
</p>
|
||||
|
||||
{/* Inline-Erweiterung für Kaufdatum / Stichtag */}
|
||||
<AnimatePresence>
|
||||
{isOneTime && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto', marginTop: 12 }}
|
||||
exit={{ opacity: 0, height: 0, marginTop: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 rounded-lg bg-slate-950/80 border border-white/10 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label htmlFor="last-license-date" className="text-white text-[11px] font-bold block">
|
||||
Datum letzter CASPOS-Kauf <span className="text-slate-400 font-normal">(optional)</span>
|
||||
</Label>
|
||||
<p className="text-[10px] text-slate-400 leading-tight">
|
||||
Zur automatischen Berechnung der Update-Staffel (15% / 30% / 50%).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="last-license-date"
|
||||
type="date"
|
||||
value={lastLicenseDate}
|
||||
onChange={(e) => setLastLicenseDate(e.target.value)}
|
||||
className="bg-slate-900 border-white/10 text-white text-xs h-8 focus:border-primary w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lastLicenseDate && (
|
||||
<div className="text-[10px] text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> Stichtag erfasst
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-2.5 border-t border-white/10 flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-400">Zahlungsintervall:</span>
|
||||
<span className="text-slate-200 font-semibold">einmalig</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
|
||||
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-4 py-2.5 flex flex-col sm:flex-row items-center justify-between gap-2 z-10">
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
className="w-full sm:w-auto border-white/10 text-white hover:bg-white/10 h-8 text-xs gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" /> Zurück zu Schritt 1
|
||||
</Button>
|
||||
|
||||
{/* Selected Model Status Chip */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-white/5 border border-white/10 text-white text-xs">
|
||||
<span className="text-slate-400">Gewähltes Modell:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0 ${
|
||||
isMonthly
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
: 'border-slate-500/30 text-slate-300 bg-slate-500/10'
|
||||
}`}
|
||||
>
|
||||
{isMonthly ? 'Monatliches Abo' : 'Einmaliger Kauf'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-semibold px-5 shadow-md shadow-primary/20 h-8 text-xs gap-1.5"
|
||||
>
|
||||
Weiter zu Schritt 3 <ChevronRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
877
shop/components/wizard/step-customer.tsx
Normal file
877
shop/components/wizard/step-customer.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
User,
|
||||
Building2,
|
||||
UserPlus,
|
||||
Search,
|
||||
Check,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
CreditCard,
|
||||
Mail,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
Building,
|
||||
} from 'lucide-react'
|
||||
import { EndCustomer } 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,
|
||||
selectedEndCustomerId,
|
||||
setSelectedEndCustomerId,
|
||||
newCustomerForm,
|
||||
setNewCustomerForm,
|
||||
handleCreateCustomer,
|
||||
isCreatingCustomer,
|
||||
nextStep,
|
||||
}: StepCustomerProps) {
|
||||
// ─── PARTNER SELECTION STATE & PAGINATION (Admins only) ──────────────────────
|
||||
const [partnerSearchTerm, setPartnerSearchTerm] = useState('')
|
||||
const [partnerPageSize, setPartnerPageSize] = useState<number>(10)
|
||||
const [partnerCurrentPage, setPartnerCurrentPage] = useState<number>(1)
|
||||
|
||||
// Drawer / Sheet state for customer creation
|
||||
const [showBankDetails, setShowBankDetails] = useState(false)
|
||||
|
||||
// Drawer visibility is mapped to customerMode === 'create'
|
||||
const isDrawerOpen = customerMode === 'create'
|
||||
|
||||
const openDrawer = () => setCustomerMode('create')
|
||||
const closeDrawer = () => {
|
||||
setCustomerMode('select')
|
||||
setShowBankDetails(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPartnerCurrentPage(1)
|
||||
}, [partnerSearchTerm, partnerPageSize])
|
||||
|
||||
// Count non-anonymized customers per partner company
|
||||
const customerCountMap = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
endCustomers.forEach((c) => {
|
||||
if (!c.is_anonymized && c.partner_id) {
|
||||
counts[c.partner_id] = (counts[c.partner_id] || 0) + 1
|
||||
}
|
||||
})
|
||||
return counts
|
||||
}, [endCustomers])
|
||||
|
||||
const filteredCompanies = useMemo(() => {
|
||||
const term = partnerSearchTerm.toLowerCase().trim()
|
||||
if (!term) return companies
|
||||
return companies.filter(
|
||||
(c: any) => c.name?.toLowerCase().includes(term) || c.id?.toLowerCase().includes(term)
|
||||
)
|
||||
}, [companies, partnerSearchTerm])
|
||||
|
||||
const totalPartnerCount = filteredCompanies.length
|
||||
const totalPartnerPages = Math.ceil(totalPartnerCount / partnerPageSize) || 1
|
||||
|
||||
const paginatedCompanies = useMemo(() => {
|
||||
const from = (partnerCurrentPage - 1) * partnerPageSize
|
||||
return filteredCompanies.slice(from, from + partnerPageSize)
|
||||
}, [filteredCompanies, partnerCurrentPage, partnerPageSize])
|
||||
|
||||
// ─── CUSTOMER SELECTION STATE & PAGINATION ──────────────────────────────────
|
||||
const [pageSize, setPageSize] = useState<number>(10)
|
||||
const [currentPage, setCurrentPage] = useState<number>(1)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [searchTerm, selectedCompanyId, pageSize])
|
||||
|
||||
// Filter customers by selected partner (if admin and selectedCompanyId != 'all')
|
||||
const customersByCompany = useMemo(() => {
|
||||
let list = endCustomers.filter((c) => !c.is_anonymized)
|
||||
if (isAdmin && selectedCompanyId && selectedCompanyId !== 'all') {
|
||||
list = list.filter((c) => c.partner_id === selectedCompanyId)
|
||||
}
|
||||
return list
|
||||
}, [endCustomers, isAdmin, selectedCompanyId])
|
||||
|
||||
// Customer search filter
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return customersByCompany
|
||||
return customersByCompany.filter(
|
||||
(c) =>
|
||||
c.company_name?.toLowerCase().includes(term) ||
|
||||
c.first_name?.toLowerCase().includes(term) ||
|
||||
c.last_name?.toLowerCase().includes(term) ||
|
||||
c.city?.toLowerCase().includes(term) ||
|
||||
c.zip?.toLowerCase().includes(term) ||
|
||||
c.email?.toLowerCase().includes(term)
|
||||
)
|
||||
}, [customersByCompany, searchTerm])
|
||||
|
||||
const totalCustomerCount = filteredCustomers.length
|
||||
const totalCustomerPages = Math.ceil(totalCustomerCount / pageSize) || 1
|
||||
|
||||
const paginatedCustomers = useMemo(() => {
|
||||
const from = (currentPage - 1) * pageSize
|
||||
return filteredCustomers.slice(from, from + pageSize)
|
||||
}, [filteredCustomers, currentPage, pageSize])
|
||||
|
||||
// Selected customer object for summary chip / info
|
||||
const activeCustomer = useMemo(() => {
|
||||
return endCustomers.find((c) => c.id === selectedEndCustomerId) || null
|
||||
}, [endCustomers, selectedEndCustomerId])
|
||||
|
||||
// Click handler for customer selection
|
||||
const handleSelectCustomer = (customer: EndCustomer) => {
|
||||
setSelectedEndCustomerId(customer.id)
|
||||
if (isAdmin && customer.partner_id && (selectedCompanyId === 'all' || !selectedCompanyId)) {
|
||||
setSelectedCompanyId(customer.partner_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission with enter or button
|
||||
const onSubmitCustomerForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newCustomerForm.company_name?.trim()) return
|
||||
await handleCreateCustomer()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col overflow-hidden relative">
|
||||
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
|
||||
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
Für wen bestellen Sie?
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
|
||||
Wählen Sie den Endkunden für die Lizenzierung aus oder legen Sie schnell einen neuen Datensatz an.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={openDrawer}
|
||||
size="sm"
|
||||
className="gap-1.5 shrink-0 bg-primary hover:bg-primary/90 text-white font-medium shadow-md shadow-primary/20 h-8 text-xs"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" /> Kunde anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ─── 2. SCROLLABLE MIDDLE CONTENT (flex-1 min-h-0 overflow-hidden) ─── */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden p-3">
|
||||
<div className={`h-full ${isAdmin ? 'grid grid-cols-1 lg:grid-cols-2 gap-3 items-stretch' : 'flex flex-col'}`}>
|
||||
{/* ─── 2A. ADMIN PARTNER SELECTION ─── */}
|
||||
{isAdmin && (
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/10 flex flex-col h-full overflow-hidden justify-between space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap shrink-0">
|
||||
<div>
|
||||
<Label className="text-white font-bold text-xs flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-primary" />
|
||||
Partner-Auswahl (Admin)
|
||||
</Label>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Filtert die Kundenliste nach Partner
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-[9px] px-1.5 py-0">
|
||||
Admin Modus
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Partner Search & Page Size */}
|
||||
<div className="flex gap-2 items-center shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Partner suchen..."
|
||||
value={partnerSearchTerm}
|
||||
onChange={(e) => setPartnerSearchTerm(e.target.value)}
|
||||
className="pl-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={partnerPageSize}
|
||||
onChange={(e) => setPartnerPageSize(Number(e.target.value))}
|
||||
className="h-8 rounded-md border border-white/10 bg-slate-900 px-2 text-[11px] text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary shrink-0"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Partner Card List (Scrollable) */}
|
||||
<div className="space-y-1.5 flex-1 min-h-0 overflow-y-auto pr-1 py-0.5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{/* Reserved Fixed Card: Alle Partner */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedCompanyId('all')
|
||||
setSelectedEndCustomerId(null)
|
||||
}}
|
||||
className={`flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all select-none ${
|
||||
selectedCompanyId === 'all' || !selectedCompanyId
|
||||
? 'border-primary bg-primary/10 shadow-sm shadow-primary/10'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-primary/20 flex items-center justify-center text-primary font-bold shrink-0">
|
||||
<Users className="w-3 h-3" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white text-xs leading-tight">
|
||||
Alle Partner (Firmenübergreifend)
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 leading-tight">
|
||||
{endCustomers.filter((c) => !c.is_anonymized).length} Kunden gesamt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(selectedCompanyId === 'all' || !selectedCompanyId) && (
|
||||
<Check className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Partner Cards */}
|
||||
{paginatedCompanies.map((c: any) => {
|
||||
const isSelected = selectedCompanyId === c.id
|
||||
const customerCount = customerCountMap[c.id] || 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setSelectedCompanyId(c.id)
|
||||
setSelectedEndCustomerId(null)
|
||||
}}
|
||||
className={`flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all select-none ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 shadow-sm shadow-primary/10'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-md bg-white/5 border border-white/10 flex items-center justify-center text-slate-300 font-bold shrink-0">
|
||||
<Building2 className="w-3 h-3 text-slate-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-white text-xs truncate leading-tight">{c.name}</p>
|
||||
{(c.street || c.zip || c.city) && (
|
||||
<p className="text-[10px] text-slate-400 truncate leading-tight">
|
||||
{[c.street, [c.zip, c.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1.5 py-0 ${
|
||||
customerCount > 0
|
||||
? 'border-green-500/30 text-green-400 bg-green-500/10'
|
||||
: 'border-white/10 text-slate-500 bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{customerCount}
|
||||
</Badge>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredCompanies.length === 0 && (
|
||||
<div className="p-2 text-center text-slate-400 text-xs bg-white/5 rounded-lg border border-white/5 my-auto">
|
||||
Keine Partner gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Partner Pagination Footer */}
|
||||
<div className="flex items-center justify-between border-t border-white/10 pt-1.5 text-[10px] text-slate-400 shrink-0">
|
||||
<div>
|
||||
{Math.min(partnerCurrentPage * partnerPageSize, totalPartnerCount)} / {totalPartnerCount} Partner
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPartnerCurrentPage(1)}
|
||||
disabled={partnerCurrentPage === 1}
|
||||
className="h-5 px-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronsLeft className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPartnerCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={partnerCurrentPage === 1}
|
||||
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
|
||||
>
|
||||
<ChevronLeft className="w-2.5 h-2.5 mr-0.5" /> Zurück
|
||||
</Button>
|
||||
<span className="px-1 text-slate-300">
|
||||
{partnerCurrentPage} / {totalPartnerPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPartnerCurrentPage((p) => Math.min(totalPartnerPages, p + 1))}
|
||||
disabled={partnerCurrentPage === totalPartnerPages}
|
||||
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
|
||||
>
|
||||
Vor <ChevronRight className="w-2.5 h-2.5 ml-0.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPartnerCurrentPage(totalPartnerPages)}
|
||||
disabled={partnerCurrentPage === totalPartnerPages}
|
||||
className="h-5 px-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronsRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 2B. CUSTOMER SELECTION LIST (Scrollable Panel) ─── */}
|
||||
<div className="p-3 rounded-xl bg-white/5 border border-white/10 flex flex-col h-full overflow-hidden justify-between space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 shrink-0">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-white font-bold text-xs flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
Kunden-Auswahl
|
||||
</Label>
|
||||
<p className="text-[10px] text-slate-400 truncate">
|
||||
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
|
||||
? `Partner: "${companies.find((c) => c.id === selectedCompanyId)?.name || selectedCompanyId}"`
|
||||
: 'Alle Endkunden'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openDrawer}
|
||||
className="border-primary/40 text-primary hover:bg-primary/10 h-7 text-xs gap-1.5 shrink-0"
|
||||
>
|
||||
<UserPlus className="w-3 h-3" /> Neuer Kunde
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Customer Search */}
|
||||
<div className="relative shrink-0">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Kunden suchen nach Name, Ort, PLZ, E-Mail..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500 text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer Card List (Isolated Scrollable Area) */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 space-y-1.5 py-0.5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{customersByCompany.length === 0 ? (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 text-center space-y-2 my-auto">
|
||||
<Building2 className="w-6 h-6 text-slate-600 mx-auto" />
|
||||
<p className="text-slate-400 text-xs">
|
||||
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
|
||||
? 'Keine Endkunden für den ausgewählten Partner vorhanden.'
|
||||
: 'Noch keine Endkunden vorhanden.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openDrawer}
|
||||
className="border-white/10 text-white text-xs h-7"
|
||||
>
|
||||
<UserPlus className="w-3 h-3 mr-1.5" /> Ersten Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="p-3 text-center text-slate-400 text-xs bg-white/5 rounded-lg border border-white/5 my-auto">
|
||||
Keine passenden Kunden für "{searchTerm}" gefunden.
|
||||
</div>
|
||||
) : (
|
||||
paginatedCustomers.map((customer) => {
|
||||
const isSelected = selectedEndCustomerId === customer.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={customer.id}
|
||||
onClick={() => handleSelectCustomer(customer)}
|
||||
className={`flex items-start p-2 rounded-lg border cursor-pointer transition-all select-none ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 shadow-md shadow-primary/5'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="end_customer"
|
||||
value={customer.id}
|
||||
checked={isSelected}
|
||||
onChange={() => handleSelectCustomer(customer)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<p className="font-semibold text-white text-xs truncate leading-tight">{customer.company_name}</p>
|
||||
{isAdmin && customer.partner_id && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 border-white/10 text-slate-400 bg-white/5 leading-tight"
|
||||
>
|
||||
Partner: {companies.find((co) => co.id === customer.partner_id)?.name || customer.partner_id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 truncate leading-tight">
|
||||
{[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>
|
||||
{customer.email && (
|
||||
<p className="text-[10px] text-slate-500 mt-0.5 flex items-center gap-1 truncate leading-tight">
|
||||
<Mail className="w-2.5 h-2.5 shrink-0" /> {customer.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer Pagination */}
|
||||
{totalCustomerCount > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-white/10 pt-1.5 text-[10px] text-slate-400 shrink-0 flex-wrap gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{Math.min(currentPage * pageSize, totalCustomerCount)} / {totalCustomerCount} Kunden
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
className="h-5 rounded border border-white/10 bg-slate-900 px-1 text-[10px] text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-5 px-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronsLeft className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
|
||||
>
|
||||
<ChevronLeft className="w-2.5 h-2.5 mr-0.5" /> Zurück
|
||||
</Button>
|
||||
<span className="px-1 text-slate-300">
|
||||
{currentPage} / {totalCustomerPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalCustomerPages, p + 1))}
|
||||
disabled={currentPage === totalCustomerPages}
|
||||
className="h-5 px-1.5 border-white/10 text-white text-[10px]"
|
||||
>
|
||||
Vor <ChevronRight className="w-2.5 h-2.5 ml-0.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(totalCustomerPages)}
|
||||
disabled={currentPage === totalCustomerPages}
|
||||
className="h-5 px-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronsRight className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
|
||||
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-4 py-2.5 flex flex-col sm:flex-row items-center justify-between gap-2 z-10">
|
||||
{/* Selected Customer Summary Chip */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{activeCustomer ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-primary/10 border border-primary/30 text-white min-w-0">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<div className="text-xs truncate">
|
||||
<span className="text-slate-400">Ausgewählt:</span>{' '}
|
||||
<strong className="text-white font-semibold">{activeCustomer.company_name}</strong>
|
||||
{(activeCustomer.city || activeCustomer.zip) && (
|
||||
<span className="text-slate-400 ml-1">
|
||||
({[activeCustomer.zip, activeCustomer.city].filter(Boolean).join(' ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>Bitte wählen Sie einen Kunden aus, um fortzufahren.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={!selectedEndCustomerId}
|
||||
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-semibold px-5 shadow-md shadow-primary/20 h-8 text-xs gap-1.5"
|
||||
>
|
||||
Weiter zu Schritt 2 <ChevronRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ─── 4. SLIDE-OVER DRAWER FOR CUSTOMER CREATION ─── */}
|
||||
<AnimatePresence>
|
||||
{isDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 overflow-hidden">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={closeDrawer}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="w-screen max-w-xl bg-slate-900 border-l border-white/10 shadow-2xl flex flex-col justify-between"
|
||||
>
|
||||
{/* Drawer Header */}
|
||||
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-slate-950/60 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/20 border border-primary/30 flex items-center justify-center text-primary">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Neuen Kunden anlegen</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{isAdmin && selectedCompanyId && selectedCompanyId !== 'all'
|
||||
? `Für Partner: ${companies.find((c) => c.id === selectedCompanyId)?.name || selectedCompanyId}`
|
||||
: 'Erstellen und direkt für die Bestellung auswählen'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeDrawer}
|
||||
className="text-slate-400 hover:text-white rounded-full h-8 w-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drawer Body / Form */}
|
||||
<form id="new-customer-form" onSubmit={onSubmitCustomerForm} className="p-5 overflow-y-auto flex-1 space-y-5 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{/* Basic Info Section */}
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-center gap-2 pb-1 border-b border-white/5">
|
||||
<Building className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||
Stammdaten & Anschrift
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">Firmenname *</Label>
|
||||
<Input
|
||||
required
|
||||
value={newCustomerForm.company_name || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, company_name: e.target.value }))
|
||||
}
|
||||
placeholder="z. B. Musterfirma GmbH & Co. KG"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">USt-IdNr.</Label>
|
||||
<Input
|
||||
value={newCustomerForm.vat_id || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, vat_id: e.target.value }))
|
||||
}
|
||||
placeholder="z. B. DE123456789"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">E-Mail</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newCustomerForm.email || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder="buchhaltung@muster.de"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">Vorname</Label>
|
||||
<Input
|
||||
value={newCustomerForm.first_name || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, first_name: e.target.value }))
|
||||
}
|
||||
placeholder="Max"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">Nachname</Label>
|
||||
<Input
|
||||
value={newCustomerForm.last_name || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, last_name: e.target.value }))
|
||||
}
|
||||
placeholder="Mustermann"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">Straße & Hausnummer</Label>
|
||||
<Input
|
||||
value={newCustomerForm.street || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, street: e.target.value }))
|
||||
}
|
||||
placeholder="Hauptstraße 12"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">PLZ</Label>
|
||||
<Input
|
||||
value={newCustomerForm.zip || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, zip: e.target.value }))
|
||||
}
|
||||
placeholder="10115"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs font-semibold">Ort</Label>
|
||||
<Input
|
||||
value={newCustomerForm.city || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
placeholder="Berlin"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Bank Details Section */}
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/40 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBankDetails((prev) => !prev)}
|
||||
className="w-full flex items-center justify-between p-3 text-left hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-3.5 h-3.5 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white">Optionale Zahlungs-/Bankdaten</p>
|
||||
<p className="text-[10px] text-slate-400">SEPA-Lastschrift, IBAN & BIC hinterlegen</p>
|
||||
</div>
|
||||
</div>
|
||||
{showBankDetails ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showBankDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="p-3.5 border-t border-white/10 space-y-3 bg-white/[0.02]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs">Kontoinhaber</Label>
|
||||
<Input
|
||||
value={newCustomerForm.bank_owner || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, bank_owner: e.target.value }))
|
||||
}
|
||||
placeholder="Max Mustermann"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs">IBAN</Label>
|
||||
<Input
|
||||
value={newCustomerForm.bank_iban || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, bank_iban: e.target.value }))
|
||||
}
|
||||
placeholder="DE89370400440532013000"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs">BIC</Label>
|
||||
<Input
|
||||
value={newCustomerForm.bank_bic || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, bank_bic: e.target.value }))
|
||||
}
|
||||
placeholder="SOLADE21XXX"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-300 text-xs">Bankname</Label>
|
||||
<Input
|
||||
value={newCustomerForm.bank_name || ''}
|
||||
onChange={(e) =>
|
||||
setNewCustomerForm((prev: any) => ({ ...prev, bank_name: e.target.value }))
|
||||
}
|
||||
placeholder="Musterbank"
|
||||
className="bg-slate-950/70 border-white/10 text-white placeholder:text-slate-500 focus:border-primary text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Drawer Footer */}
|
||||
<div className="p-4 border-t border-white/10 bg-slate-950/60 flex items-center justify-end gap-2.5 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={closeDrawer}
|
||||
disabled={isCreatingCustomer}
|
||||
className="text-slate-300 hover:text-white h-8 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-customer-form"
|
||||
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white font-semibold px-4 h-8 text-xs"
|
||||
>
|
||||
{isCreatingCustomer ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
Speichere Kunde...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
Kunden anlegen & auswählen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
416
shop/components/wizard/step-software.tsx
Normal file
416
shop/components/wizard/step-software.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { ShoppingCart, Check, AlertCircle, Lock, X, Info } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
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
|
||||
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
|
||||
existingModuleIds?: string[]
|
||||
activeCategoryId: string | null
|
||||
editingDeviceName?: string | null
|
||||
onCancelEdit?: () => void
|
||||
productValidationErrors?: string[]
|
||||
}
|
||||
|
||||
export 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,
|
||||
existingModuleIds = [],
|
||||
activeCategoryId,
|
||||
editingDeviceName = null,
|
||||
onCancelEdit,
|
||||
productValidationErrors = [],
|
||||
}: StepSoftwareProps) {
|
||||
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
|
||||
|
||||
if (!currentCategory) return null
|
||||
|
||||
const catProducts = products.filter(p => {
|
||||
if (p.category_id !== currentCategory.id) return false
|
||||
return selectedBillingInterval === 'one_time'
|
||||
? p.show_in_kauf !== false
|
||||
: p.show_in_abo !== false
|
||||
})
|
||||
const sel = selections[currentCategory.id]
|
||||
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||
|
||||
return (
|
||||
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl overflow-hidden min-h-0">
|
||||
{/* ─── FIXED CARD HEADER (shrink-0 mb-1) ─── */}
|
||||
<CardHeader className="border-b border-white/10 py-2.5 px-4 shrink-0 bg-slate-950/40">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-lg bg-primary/20 text-primary border border-primary/30 shrink-0">
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-bold text-white leading-tight">
|
||||
{currentCategory.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] text-slate-400 mt-0.5 leading-tight">
|
||||
{currentCategory.description || 'Passen Sie die Konfiguration für diese Kategorie an.'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingDeviceName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-bold gap-1.5 px-2.5 py-0.5 rounded-lg animate-pulse shrink-0 flex items-center">
|
||||
<Icons.Pencil className="w-3 h-3 text-amber-400 mr-1" /> Kasse: "{editingDeviceName}"
|
||||
</Badge>
|
||||
{onCancelEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCancelEdit}
|
||||
className="h-6 text-[11px] text-slate-400 hover:text-white hover:bg-white/10 px-2"
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" /> Neuer Entwurf
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ─── SCROLLABLE CONTENT AREA (flex-1 min-h-0 min-w-0 overflow-y-auto pr-3 pb-12) ─── */}
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-4 pr-3 pb-12 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentCategory.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-4 min-w-0"
|
||||
>
|
||||
{/* Category selection status header */}
|
||||
{(() => {
|
||||
const isMissingRequired = currentCategory.is_required && !sel?.productId && (!sel?.productIds || sel.productIds.length === 0)
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
|
||||
isMissingRequired
|
||||
? 'bg-red-500/10 border-red-500/50 text-red-400 font-bold'
|
||||
: 'bg-white/5 border-white/10'
|
||||
}`}>
|
||||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
isMissingRequired ? 'bg-red-500/20 text-red-400' : 'bg-primary/20 text-primary'
|
||||
}`}>
|
||||
<CategoryIcon icon={currentCategory.icon} className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold text-white text-xs flex items-center gap-2 flex-wrap">
|
||||
<span>{currentCategory.name}</span>
|
||||
{isMissingRequired && (
|
||||
<span className="text-[10px] bg-red-500/20 text-red-400 border border-red-500/40 px-2 py-0.2 rounded-full font-extrabold animate-pulse tracking-wider">
|
||||
PFLICHTFELD AUSWÄHLEN!
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-[10px] mt-0.5">
|
||||
{currentCategory.allow_multiselect ? 'Mehrfachauswahl möglich' : 'Einzelauswahl'}
|
||||
</p>
|
||||
</div>
|
||||
{sel?.productIds && sel.productIds.length > 0 ? (
|
||||
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-[11px] rounded-full shrink-0">
|
||||
<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 text-[11px] rounded-full shrink-0">
|
||||
<Check className="w-3 h-3 mr-1" /> Ausgewählt
|
||||
</Badge>
|
||||
) : currentCategory.is_required ? (
|
||||
<Badge variant="destructive" className="ml-auto opacity-90 text-[11px] rounded-full bg-red-500/30 text-red-400 border border-red-500/50 animate-pulse font-extrabold shrink-0">
|
||||
<AlertCircle className="w-3 h-3 mr-1 text-red-400" /> Bitte auswählen!
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="ml-auto border-white/10 text-slate-400 text-[11px] rounded-full bg-white/5 shrink-0">
|
||||
Optional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Products List (grid-cols-1 xl:grid-cols-2 gap-3) */}
|
||||
{catProducts.length === 0 ? (
|
||||
<p className="text-slate-500 text-xs italic p-4 bg-white/5 rounded-xl border border-white/5 text-center">
|
||||
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||
</p>
|
||||
) : currentCategory.allow_multiselect ? (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
|
||||
{catProducts.map(product => {
|
||||
const isChecked = sel?.productIds?.includes(product.id) ?? false
|
||||
const disabled = isProductDisabled(product, currentCategory.id)
|
||||
return (
|
||||
<div key={product.id} className="relative min-w-0">
|
||||
<Label
|
||||
onClick={() => !disabled && selectProduct(currentCategory.id, product.id)}
|
||||
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
|
||||
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
|
||||
: isChecked
|
||||
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 w-full">
|
||||
<div className="flex justify-between items-start gap-2 w-full">
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => { }}
|
||||
className="border-white/20 data-[state=checked]:bg-primary rounded mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
|
||||
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-primary font-bold text-xs shrink-0 text-right">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={sel?.productId ?? ''}
|
||||
onValueChange={id => selectProduct(currentCategory.id, id)}
|
||||
className="grid grid-cols-1 xl:grid-cols-2 gap-3"
|
||||
>
|
||||
{catProducts.map(product => {
|
||||
const disabled = isProductDisabled(product, currentCategory.id)
|
||||
const isChecked = sel?.productId === product.id
|
||||
return (
|
||||
<div key={product.id} className="relative min-w-0">
|
||||
<RadioGroupItem
|
||||
value={product.id}
|
||||
id={`${currentCategory.id}-${product.id}`}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={disabled ? undefined : `${currentCategory.id}-${product.id}`}
|
||||
className={`flex flex-col justify-between h-full p-3 rounded-xl border transition-all ${disabled
|
||||
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
|
||||
: isChecked
|
||||
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 w-full">
|
||||
<div className="flex justify-between items-start gap-2 w-full">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-bold text-xs text-white block break-words">{product.name}</span>
|
||||
<span className={`inline-block text-[9px] px-1.5 py-0.2 mt-1 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary font-bold text-xs shrink-0 text-right">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-[10px] text-slate-500 font-normal block">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-2 font-normal leading-relaxed break-words">{product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-[10px] text-slate-500 mt-2 font-normal block">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{/* Modules Section */}
|
||||
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
|
||||
<div className="mt-4 p-3.5 rounded-xl bg-white/5 border border-white/10 space-y-3 min-w-0">
|
||||
<p className="text-xs font-bold text-white flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
Zusatzmodule für {selectedProduct.name}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-2.5">
|
||||
{selectedProduct.modules.map(module => {
|
||||
const isExistingLicense = existingModuleIds.includes(module.id)
|
||||
const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? [])
|
||||
const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false)
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
title={isExistingLicense ? "Dieses Modul ist auf dieser Kasse bereits aktiv und dauerhaft lizenziert." : undefined}
|
||||
className={`flex flex-col justify-between p-3 rounded-xl border transition-all duration-200 min-w-0 ${
|
||||
isExistingLicense
|
||||
? 'border-primary/20 bg-primary/5 opacity-75'
|
||||
: disabled
|
||||
? 'border-white/5 bg-white/5 opacity-40'
|
||||
: checked
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_15px_rgba(59,130,246,0.15)] ring-1 ring-primary/40 text-white'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-2.5 min-w-0">
|
||||
<Checkbox
|
||||
id={`mod-${currentCategory.id}-${module.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={() => !isExistingLicense && toggleModule(currentCategory.id, module.id)}
|
||||
disabled={disabled}
|
||||
className="rounded border-white/20 data-[state=checked]:bg-primary mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label
|
||||
htmlFor={isExistingLicense ? undefined : `mod-${currentCategory.id}-${module.id}`}
|
||||
className={`font-semibold text-xs flex justify-between items-start text-white gap-2 ${
|
||||
isExistingLicense ? 'cursor-default' : disabled ? 'cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0 break-words">
|
||||
{module.name}
|
||||
{isExistingLicense && (
|
||||
<span className="inline-flex items-center gap-1 text-[9px] px-1.5 py-0.2 rounded bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-wider ml-1 mt-0.5">
|
||||
<Lock className="w-2.5 h-2.5" /> Lizenziert
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-primary font-bold text-xs shrink-0 text-right">
|
||||
{isExistingLicense ? (
|
||||
<span className="text-slate-500 text-[11px] font-normal">inkl.</span>
|
||||
) : (
|
||||
<>+{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}</>
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
{module.description && (
|
||||
<p className="text-[11px] text-slate-400 mt-1 leading-relaxed break-words">{module.description}</p>
|
||||
)}
|
||||
{!isExistingLicense && disabled && (
|
||||
<p className="text-[10px] text-red-400 mt-1 font-medium flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3 text-red-400 shrink-0" />
|
||||
{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 && !isExistingLicense && module.has_quantity && (
|
||||
<div className="flex items-center gap-3 mt-2.5 pl-6 pt-2 border-t border-white/5">
|
||||
<Label htmlFor={`qty-${module.id}`} className="text-[11px] text-slate-400 font-medium shrink-0">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-14 h-6 bg-white/5 border-white/10 text-white text-xs text-center rounded focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400 truncate">
|
||||
Gesamt:{' '}
|
||||
<span className="text-primary font-bold">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price * (moduleQuantities[module.id] || 1))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Validation Warnings */}
|
||||
{productValidationErrors.length > 0 && (
|
||||
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 space-y-1">
|
||||
{productValidationErrors.map((err, errIdx) => (
|
||||
<p key={errIdx} className="text-xs text-red-400 flex items-center gap-1.5 font-medium">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0 text-red-400" />
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
590
shop/components/wizard/step-summary.tsx
Normal file
590
shop/components/wizard/step-summary.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } 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 { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
ShieldCheck,
|
||||
Building2,
|
||||
Calendar,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Lock,
|
||||
Check,
|
||||
X,
|
||||
CreditCard,
|
||||
User,
|
||||
MapPin,
|
||||
Send,
|
||||
} 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
|
||||
linkedFeeProducts?: Product[]
|
||||
orderNotes: string
|
||||
setOrderNotes: (notes: string) => void
|
||||
onEditBasketItem?: (idx: number) => void
|
||||
onAddNewBasketItem?: () => void
|
||||
onDeleteBasketItem?: (idx: number) => 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,
|
||||
linkedFeeProducts = [],
|
||||
orderNotes,
|
||||
setOrderNotes,
|
||||
onEditBasketItem,
|
||||
onAddNewBasketItem,
|
||||
onDeleteBasketItem,
|
||||
}: StepSummaryProps) {
|
||||
const [confirmDeleteIdx, setConfirmDeleteIdx] = useState<number | null>(null)
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
|
||||
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
|
||||
const totalPositionsCount = finalItemsToShow.length
|
||||
const primaryInterval = finalItemsToShow[0]?.billingInterval || 'monthly'
|
||||
|
||||
return (
|
||||
<Card className="glass-dark border-white/10 h-[calc(100vh-8.5rem)] flex flex-col justify-between overflow-hidden relative">
|
||||
{/* ─── 1. FIXED HEADER (shrink-0) ─── */}
|
||||
<CardHeader className="py-2.5 px-4 shrink-0 border-b border-white/10 bg-slate-950/40 space-y-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-primary/20 rounded-lg flex items-center justify-center border border-primary/40 shrink-0 text-primary">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold text-white flex items-center gap-2">
|
||||
Anfrage prüfen & absenden
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] text-slate-300 mt-0.5">
|
||||
Überprüfen Sie alle konfigurierten Kassen, Positionen und Konditionen vor der Freigabe.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-primary/30 text-primary bg-primary/10 text-xs px-2.5 py-0.5">
|
||||
Schritt 4 von 4
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ─── 2. SCROLLABLE MIDDLE (flex-1 min-h-0 overflow-y-auto) ─── */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 max-w-7xl mx-auto w-full items-start">
|
||||
{/* ─── LINKES RASTER (lg:col-span-7) – Auftragsübersicht & Kassen ─── */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
{/* Kopf-Card: Endkunde & Abrechnungsmodell */}
|
||||
<div className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 pb-2.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-primary font-bold text-xs uppercase tracking-wider">
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span>Kunde & Abrechnung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] bg-primary/10 border-primary/30 text-primary font-semibold">
|
||||
{primaryInterval === 'one_time' ? 'Einmalkauf' : 'Monatliches Abo'}
|
||||
</Badge>
|
||||
{lastLicenseDate && (
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/30 text-emerald-400">
|
||||
Stichtag: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEndCustomer ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-bold text-white">{selectedEndCustomer.company_name}</h4>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-300 flex-wrap">
|
||||
{(selectedEndCustomer.first_name || selectedEndCustomer.last_name) && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3 text-slate-400" />
|
||||
{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
)}
|
||||
{(selectedEndCustomer.street || selectedEndCustomer.zip || selectedEndCustomer.city) && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 text-slate-400" />
|
||||
{[selectedEndCustomer.street, [selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(' ')].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{selectedEndCustomer.vat_id && (
|
||||
<span className="text-slate-400">USt-IdNr: {selectedEndCustomer.vat_id}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">Kein Endkunde zugewiesen (Partner-Bestellung).</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kassenliste */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
|
||||
<span>Enthaltene Kassen ({finalItemsToShow.length})</span>
|
||||
</h3>
|
||||
{onAddNewBasketItem && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAddNewBasketItem}
|
||||
className="border-dashed border-primary/40 text-primary hover:bg-primary/10 text-xs font-semibold gap-1.5 h-7"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Weitere Kasse hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{finalItemsToShow.length === 0 ? (
|
||||
<div className="p-6 rounded-2xl bg-white/5 border border-white/10 text-center space-y-2">
|
||||
<p className="text-xs text-slate-400 font-medium">Keine Kassen in der Aufstellung vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
finalItemsToShow.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-3 relative overflow-hidden"
|
||||
>
|
||||
{/* Kassen Header Bar */}
|
||||
<div className="flex justify-between items-center bg-slate-950/40 p-2.5 rounded-xl border border-white/5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-white font-bold text-xs truncate">
|
||||
{item.deviceName || `Kasse ${itemIdx + 1}`}
|
||||
</span>
|
||||
{itemIdx > 0 && (
|
||||
<Badge variant="outline" className="text-[9px] px-1.5 py-0 border-emerald-500/30 text-emerald-400 bg-emerald-500/10 shrink-0">
|
||||
Kasse 2+
|
||||
</Badge>
|
||||
)}
|
||||
{item.licenseNumber && (
|
||||
<span className="text-[10px] text-slate-400 font-mono truncate">
|
||||
({item.licenseNumber})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-2 py-0.5 font-semibold ${
|
||||
item.billingInterval === 'one_time'
|
||||
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
|
||||
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
|
||||
}`}
|
||||
>
|
||||
{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}
|
||||
</Badge>
|
||||
|
||||
{onEditBasketItem && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditBasketItem(itemIdx)}
|
||||
className="h-7 px-2 text-xs text-slate-300 hover:text-primary hover:bg-primary/20 gap-1 rounded-lg"
|
||||
title="Kasse in Schritt 3 bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3 h-3" /> Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDeleteBasketItem && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmDeleteIdx === itemIdx ? 'destructive' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirmDeleteIdx === itemIdx) {
|
||||
onDeleteBasketItem(itemIdx)
|
||||
setConfirmDeleteIdx(null)
|
||||
} else {
|
||||
setConfirmDeleteIdx(itemIdx)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setConfirmDeleteIdx(null)}
|
||||
className={`h-7 px-2 text-xs transition-all ${
|
||||
confirmDeleteIdx === itemIdx
|
||||
? 'bg-red-600 text-white hover:bg-red-700 font-bold px-2.5 shadow-md shadow-red-500/30'
|
||||
: 'text-slate-400 hover:text-red-400 hover:bg-red-500/20'
|
||||
} gap-1 rounded-lg`}
|
||||
title="Kasse entfernen"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{confirmDeleteIdx === itemIdx ? 'Wirklich löschen?' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enthaltene Produkte & Module */}
|
||||
<div className="space-y-2 text-xs">
|
||||
{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-1">
|
||||
<span className="text-slate-400 text-[10px] font-bold uppercase tracking-wider block">
|
||||
{cat.name}:
|
||||
</span>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const isAbo = item.billingInterval === 'monthly'
|
||||
const displayPrice = isFree ? 0 : isAbo ? ((prod as any).monthly_price ?? prod.base_price) : prod.base_price
|
||||
|
||||
return (
|
||||
<div key={prod.id} className="flex justify-between items-center text-slate-200 pl-2">
|
||||
<span className="flex items-center gap-1.5 truncate pr-2">
|
||||
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<span className="truncate">{prod.name}</span>
|
||||
{isFree && (
|
||||
<span className="text-[9px] bg-green-500/20 text-green-400 px-1.5 py-0.2 rounded border border-green-500/30 font-bold uppercase shrink-0">
|
||||
Inklusive
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-bold text-xs text-white tabular-nums shrink-0">
|
||||
{isFree ? '0,00 €' : fmt(displayPrice)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Selected Module Chips */}
|
||||
{sel?.moduleIds && sel.moduleIds.length > 0 && (
|
||||
<div className="pl-6 space-y-1 pt-0.5">
|
||||
{sel.moduleIds.map((mId: string) => {
|
||||
let modObj: any = null
|
||||
products.forEach((p) => {
|
||||
const found = p.modules?.find((m) => m.id === mId)
|
||||
if (found) modObj = found
|
||||
})
|
||||
if (!modObj) return null
|
||||
|
||||
const isAbo = item.billingInterval === 'monthly'
|
||||
const qty = item.moduleQuantities?.[mId] || 1
|
||||
const unitPrice = isAbo ? (modObj.monthly_price ?? modObj.price) : modObj.price
|
||||
const totalPrice = unitPrice * qty
|
||||
|
||||
return (
|
||||
<div key={mId} className="flex justify-between text-slate-400 text-[11px]">
|
||||
<span className="truncate pr-2">
|
||||
+ {modObj.name} {qty > 1 ? `(${qty}x)` : ''}
|
||||
</span>
|
||||
<span className="font-semibold text-slate-300 tabular-nums shrink-0">
|
||||
{fmt(totalPrice)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Service- & Zusatz-Positionen */}
|
||||
{linkedFeeProducts.length > 0 && (
|
||||
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 space-y-2">
|
||||
<span className="text-slate-400 text-[10px] font-bold uppercase tracking-wider block">
|
||||
Service & Gebühren:
|
||||
</span>
|
||||
{linkedFeeProducts.map((fp) => (
|
||||
<div key={fp.id} className="flex justify-between text-xs text-slate-300">
|
||||
<span>{fp.name}</span>
|
||||
<span className="font-bold text-white tabular-nums">{fmt(fp.base_price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── RECHTES RASTER (lg:col-span-5) – Notizen, Bedingungen & Summen ─── */}
|
||||
<div className="lg:col-span-5 space-y-5">
|
||||
{/* Glassmorphism Preiskalkulation */}
|
||||
<div className="p-5 rounded-2xl bg-slate-950/80 border border-white/10 space-y-3 shadow-xl">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-white/10">
|
||||
<CreditCard className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Gesamte Preiskalkulation
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{oneTimeTotal > 0 && (
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Einmalig (netto):</span>
|
||||
<span className="tabular-nums font-mono text-white">{fmt(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span className="tabular-nums font-mono text-white">{fmt(oneTimeTax)}</span>
|
||||
</div>
|
||||
{updatePriceModifier?.label && (
|
||||
<div className="text-[11px] text-green-400 font-semibold bg-green-500/10 p-2 rounded-lg border border-green-500/20 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-bold text-white pt-2 border-t border-white/5">
|
||||
<span>Einmalig gesamt (brutto):</span>
|
||||
<span className="tabular-nums font-mono text-primary font-extrabold text-base">{fmt(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthlyTotal > 0 && (
|
||||
<div className={`space-y-1.5 text-xs ${oneTimeTotal > 0 ? 'pt-3 border-t border-white/10' : ''}`}>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Monatlich (netto):</span>
|
||||
<span className="tabular-nums font-mono text-white">{fmt(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span className="tabular-nums font-mono text-white">{fmt(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-white pt-2 border-t border-white/5">
|
||||
<span>Monatlich gesamt (brutto):</span>
|
||||
<span className="tabular-nums font-mono text-primary font-extrabold text-base">{fmt(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bestellnotizen */}
|
||||
<div className="p-4 rounded-2xl bg-white/5 border border-white/10 space-y-2">
|
||||
<Label htmlFor="order-notes" className="text-xs font-bold text-white uppercase tracking-wider flex items-center gap-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary" />
|
||||
<span>Bestellnotizen / Bemerkungen (optional)</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="order-notes"
|
||||
rows={3}
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
placeholder="Besondere Hinweise, Wunschtermine oder Ansprechpartner..."
|
||||
className="w-full p-3 rounded-xl bg-slate-950/70 border border-white/10 text-xs text-white placeholder:text-slate-500 focus:border-primary focus:outline-none transition-colors resize-none scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 3. FIXED BOTTOM ACTION BAR (shrink-0) ─── */}
|
||||
<div className="shrink-0 border-t border-white/10 bg-slate-950/90 backdrop-blur-md px-6 py-3.5 flex flex-col sm:flex-row items-center justify-between gap-3 z-10">
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
className="w-full sm:w-auto border-white/10 text-white hover:bg-white/10 h-9 text-xs gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Zurück zu Schritt 3
|
||||
</Button>
|
||||
|
||||
{/* Info Chip */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-white text-xs">
|
||||
<span className="text-slate-400">Positionen:</span>
|
||||
<strong className="text-white">{finalItemsToShow.length} Kassen</strong>
|
||||
<span className="text-slate-600">|</span>
|
||||
<span className="text-primary font-bold">
|
||||
{oneTimeTotal > 0 ? fmt(oneTimeGross) : `${fmt(monthlyGross)} / mtl.`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Submit Button -> opens modal */}
|
||||
<Button
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={isSubmitting || finalItemsToShow.length === 0}
|
||||
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white font-bold px-6 shadow-lg shadow-primary/20 h-9 text-xs gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{initialOrder ? 'Anfrage wird aktualisiert...' : 'Anfrage wird übertragen...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{initialOrder ? 'Anfrage aktualisieren' : 'Anfrage verbindlich absenden'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── 4. BESTÄTIGUNGS-POPUP / MODAL VOR DEM ABSENDEN ─── */}
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-5 text-white">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-primary/20 text-primary border border-primary/30">
|
||||
<ShieldCheck className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">Richtigkeit & Freigabe bestätigen</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Letzter Schritt vor Übermittlung der Lizenzierung</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
disabled={isSubmitting}
|
||||
className="h-8 w-8 p-0 text-slate-400 hover:text-white rounded-lg"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs text-slate-300">
|
||||
<div className="p-3.5 rounded-xl bg-white/5 border border-white/10 space-y-2">
|
||||
<div className="flex justify-between items-center text-white font-semibold">
|
||||
<span>Endkunde:</span>
|
||||
<span className="text-primary">{selectedEndCustomer?.company_name || 'Kein Kunde gewählt'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-slate-400">
|
||||
<span>Anzahl Geräte:</span>
|
||||
<span className="text-white font-medium">{finalItemsToShow.length} Kassen</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-slate-400">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span className="text-white font-bold">{oneTimeTotal > 0 ? fmt(oneTimeGross) : `${fmt(monthlyGross)} / mtl.`}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 rounded-xl bg-slate-950/80 border border-white/10 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="modal-terms-checkbox"
|
||||
checked={acceptedTerms}
|
||||
onCheckedChange={(checked) => setAcceptedTerms(checked === true)}
|
||||
className="rounded border-white/20 data-[state=checked]:bg-primary mt-0.5"
|
||||
/>
|
||||
<Label htmlFor="modal-terms-checkbox" className="text-xs text-slate-300 leading-relaxed cursor-pointer select-none">
|
||||
Ich bestätige die Richtigkeit aller gemachten Angaben und akzeptiere die Lizenzvereinbarungen sowie die{' '}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-white text-primary">
|
||||
Datenschutzerklärung
|
||||
</a>.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="p-2.5 rounded-lg bg-primary/10 border border-primary/20 text-[11px] text-slate-300 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>B2B-Freigabe: Nach Absenden wird die Lizenzierungsanfrage geprüft und freigegeben.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2 border-t border-white/10">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
disabled={isSubmitting}
|
||||
className="border-white/10 text-white hover:bg-white/10 text-xs h-9 px-4"
|
||||
>
|
||||
Abbrechen & Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleSubmit()
|
||||
setShowConfirmModal(false)
|
||||
}}
|
||||
disabled={!acceptedTerms || isSubmitting}
|
||||
className="bg-primary hover:bg-primary/90 text-white font-bold text-xs h-9 px-5 gap-2 shadow-md shadow-primary/20"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Wird übermittelt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Jetzt verbindlich absenden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
264
shop/components/wizard/summary-sidebar.tsx
Normal file
264
shop/components/wizard/summary-sidebar.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
AlertCircle,
|
||||
Check,
|
||||
ShoppingCart,
|
||||
Save,
|
||||
Plus,
|
||||
} 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[]
|
||||
editingIdx: number | null
|
||||
deviceName: string
|
||||
setDeviceName: (name: string) => void
|
||||
licenseNumber: string
|
||||
setLicenseNumber: (num: string) => void
|
||||
onSaveAndProceed: () => void
|
||||
isNextStepDisabled: boolean
|
||||
hasActiveSelection: boolean
|
||||
showValidationWarning?: boolean
|
||||
prevStep: () => void
|
||||
}
|
||||
|
||||
export function SummarySidebar({
|
||||
visibleCategories,
|
||||
selections,
|
||||
products,
|
||||
moduleQuantities,
|
||||
billingLabel,
|
||||
oneTimeTotal,
|
||||
monthlyTotal,
|
||||
oneTimeNet,
|
||||
oneTimeTax,
|
||||
oneTimeGross,
|
||||
monthlyNet,
|
||||
monthlyTax,
|
||||
monthlyGross,
|
||||
updatePriceModifier,
|
||||
allCategoriesFilled,
|
||||
productValidationErrors,
|
||||
editingIdx,
|
||||
deviceName,
|
||||
setDeviceName,
|
||||
licenseNumber,
|
||||
setLicenseNumber,
|
||||
onSaveAndProceed,
|
||||
isNextStepDisabled,
|
||||
prevStep,
|
||||
}: SummarySidebarProps) {
|
||||
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
|
||||
const isEditing = editingIdx !== null
|
||||
const currentDeviceTitle = isEditing
|
||||
? `Kasse ${editingIdx + 1}`
|
||||
: (deviceName.trim() || 'Neue Kasse')
|
||||
|
||||
const isSaveDisabled = !allCategoriesFilled || productValidationErrors.length > 0
|
||||
|
||||
return (
|
||||
<Card className="glass-dark border-white/10 shadow-2xl rounded-2xl overflow-hidden flex flex-col h-full bg-slate-900/90 backdrop-blur-xl">
|
||||
{/* ─── 1. TOP HEADER (shrink-0) ─── */}
|
||||
<CardHeader className="shrink-0 py-2.5 px-4 border-b border-white/10 bg-slate-950/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-white text-sm font-bold flex items-center gap-2">
|
||||
<ShoppingCart className="w-4 h-4 text-primary" />
|
||||
<span>Kassen-Konfiguration</span>
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold ${
|
||||
isEditing
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
|
||||
: 'border-primary/40 bg-primary/10 text-primary'
|
||||
}`}
|
||||
>
|
||||
{isEditing ? `Bearbeite #${editingIdx + 1}` : 'Neuer Eintrag'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ─── 2. SCROLLABLE MIDDLE (flex-1 min-h-0 overflow-y-auto) ─── */}
|
||||
<CardContent className="p-4 flex-1 min-h-0 overflow-y-auto space-y-4 scrollbar-thin scrollbar-thumb-slate-800 scrollbar-track-transparent">
|
||||
{/* Aktuelle Kassen-Auswahl */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-300 uppercase tracking-wider">
|
||||
<span>Gewählte Positionen</span>
|
||||
</div>
|
||||
|
||||
{/* Selected Categories List */}
|
||||
<div className="space-y-2">
|
||||
{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-[11px] text-slate-500 italic flex items-center gap-1.5">
|
||||
{cat.is_required ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive shrink-0" />
|
||||
) : (
|
||||
<span className="w-3 h-3 inline-block shrink-0" />
|
||||
)}
|
||||
<span>{cat.name}: {cat.is_required ? 'nicht gewählt (Pflicht)' : 'nicht gewählt (optional)'}</span>
|
||||
</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-0.5 text-xs">
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wider">{cat.name}</p>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const actualPrice = isFree ? 0 : prod.base_price
|
||||
return (
|
||||
<div key={prod.id} className="pl-1.5 space-y-0.5">
|
||||
<div className="flex justify-between text-white text-xs">
|
||||
<span className="truncate pr-2">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Inkl.)</span>}</span>
|
||||
<span className="tabular-nums shrink-0 font-medium">
|
||||
{fmt(actualPrice)}
|
||||
</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-[11px] text-slate-400 pl-2">
|
||||
<span className="truncate pr-2">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
|
||||
<span className="tabular-nums shrink-0 font-medium text-slate-300">
|
||||
{fmt(mod.price * qty)}
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pricing Box */}
|
||||
<div className="p-3 rounded-xl bg-slate-950/80 border border-white/10 space-y-2 text-xs">
|
||||
{oneTimeTotal > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Kaufpreis Kasse (netto):</span>
|
||||
<span className="tabular-nums font-mono">{fmt(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-white">
|
||||
<span>Kaufpreis Kasse (brutto):</span>
|
||||
<span className="tabular-nums font-mono text-primary">{fmt(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{monthlyTotal > 0 && (
|
||||
<div className={`space-y-1 ${oneTimeTotal > 0 ? 'pt-2 border-t border-white/10' : ''}`}>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Mietpreis Kasse (netto):</span>
|
||||
<span className="tabular-nums font-mono">{fmt(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-white">
|
||||
<span>Mietpreis Kasse (brutto):</span>
|
||||
<span className="tabular-nums font-mono text-primary">{fmt(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gerätename & Lizenznummer Eingabe */}
|
||||
<div className="pt-2 border-t border-white/10 space-y-2.5">
|
||||
<p className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
{isEditing ? `Kasse anpassen` : `Kassenbezeichnung`}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="device-name-sidebar" className="text-[11px] text-slate-400">Kassenname (optional)</Label>
|
||||
<Input
|
||||
id="device-name-sidebar"
|
||||
placeholder="z. B. Kasse 1, Hauptkasse, Theke"
|
||||
value={deviceName}
|
||||
onChange={e => setDeviceName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="license-number-sidebar" className="text-[11px] text-slate-400">Lizenznummer (optional)</Label>
|
||||
<Input
|
||||
id="license-number-sidebar"
|
||||
placeholder="z. B. LIZ-12345"
|
||||
value={licenseNumber}
|
||||
onChange={e => setLicenseNumber(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white text-xs h-8 rounded-lg font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* ─── 3. FIXED BOTTOM ACTIONS (shrink-0) ─── */}
|
||||
<CardFooter className="flex flex-col gap-2 shrink-0 p-4 border-t border-white/10 bg-slate-950/90">
|
||||
{/* Primary Action Button: Save & Proceed immediately to Step 4 */}
|
||||
<Button
|
||||
className="w-full h-11 text-xs font-bold bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20 gap-2"
|
||||
onClick={onSaveAndProceed}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Save className="w-4 h-4" /> Änderungen speichern & zur Übersicht <ChevronRight className="w-4 h-4 ml-0.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" /> Kasse zum Auftrag hinzufügen <ChevronRight className="w-4 h-4 ml-0.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-slate-400 hover:text-white h-7 text-xs"
|
||||
onClick={prevStep}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3 mr-1" /> 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, X } 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}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { headers } from 'next/headers'
|
||||
import { sendLockoutEmail } from '@/lib/utils/email'
|
||||
import { sendMail } from '@/utils/mail'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export async function signIn(email: string, password: string) {
|
||||
export async function signIn(email: string, password: string, deviceHash?: string) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
@@ -14,32 +15,6 @@ export async function signIn(email: string, password: string) {
|
||||
password,
|
||||
})
|
||||
if (error) {
|
||||
// Track failed attempts
|
||||
const { data: usr, error: usrError } = await supabase
|
||||
.from('users')
|
||||
.select('failed_attempts, email')
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
const attempts = (usr?.failed_attempts ?? 0) + 1
|
||||
|
||||
// Update attempts count
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ failed_attempts: attempts })
|
||||
.eq('email', email)
|
||||
|
||||
// Lock account on 5th failure
|
||||
if (attempts >= 5) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ role: 'gesperrt' })
|
||||
.eq('email', email)
|
||||
// Notify admin
|
||||
await sendLockoutEmail('info@hephex.de')
|
||||
return { success: false, error: 'Konto gesperrt nach 5 Fehlversuchen.' }
|
||||
}
|
||||
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
@@ -76,7 +51,7 @@ export async function signIn(email: string, password: string) {
|
||||
}
|
||||
}
|
||||
|
||||
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) {
|
||||
if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin' && userData.role !== 'verwaltung')) {
|
||||
await supabase.auth.signOut()
|
||||
if (userData?.role === 'gesperrt') {
|
||||
return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." }
|
||||
@@ -84,6 +59,23 @@ export async function signIn(email: string, password: string) {
|
||||
return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." }
|
||||
}
|
||||
|
||||
// --- 2FA: Geräteprüfung ---
|
||||
if (deviceHash) {
|
||||
const { data: knownDevice } = await adminClient
|
||||
.from('known_devices')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('device_hash', deviceHash)
|
||||
.maybeSingle()
|
||||
|
||||
if (!knownDevice) {
|
||||
// Gerät unbekannt -> 2FA erforderlich
|
||||
// Session bleibt aktiv, aber Client muss 2FA bestätigen
|
||||
await send2FACodeInternal(userId, deviceHash, email)
|
||||
return { success: true, requires2FA: true, userId, role: userData.role }
|
||||
}
|
||||
}
|
||||
|
||||
// Andere aktive Sitzungen beenden
|
||||
await supabase.auth.signOut({ scope: 'others' })
|
||||
|
||||
@@ -146,6 +138,179 @@ function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?:
|
||||
`;
|
||||
}
|
||||
|
||||
function get2FAEmailHtml(code: string) {
|
||||
const digits = code.split('')
|
||||
const digitBoxes = digits.map(d =>
|
||||
`<td style="width: 48px; height: 56px; text-align: center; vertical-align: middle; font-size: 28px; font-weight: 800; color: #1e293b; background-color: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; font-family: 'Courier New', monospace; letter-spacing: 0;">${d}</td>`
|
||||
).join('<td style="width: 8px;"></td>')
|
||||
|
||||
const messageHtml = `
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">
|
||||
Wir haben eine Anmeldung von einem neuen Gerät erkannt. Bitte bestätigen Sie Ihre Identität mit folgendem Sicherheitscode:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||
<tr>${digitBoxes}</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-radius: 12px; padding: 16px 20px; margin: 24px 0; border-left: 4px solid #f59e0b;">
|
||||
<p style="color: #92400e; font-size: 14px; margin: 0; line-height: 1.5;">
|
||||
<strong>Dieser Code ist 15 Minuten gültig.</strong><br>
|
||||
Falls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 24px;">
|
||||
Dieser Code dient der Sicherheit Ihres Kontos. Geben Sie ihn niemals an andere Personen weiter.
|
||||
</p>
|
||||
`
|
||||
|
||||
return getBeautifulEmailHtml('Sicherheitscode', messageHtml)
|
||||
}
|
||||
|
||||
/** Internal: generates code + sends email (no auth check needed) */
|
||||
async function send2FACodeInternal(userId: string, deviceHash: string, email: string) {
|
||||
const adminClient = createAdminClient()
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString()
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString()
|
||||
|
||||
// Alte Codes für dieses Gerät löschen
|
||||
await adminClient
|
||||
.from('device_verification_codes')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('device_hash', deviceHash)
|
||||
|
||||
// Neuen Code speichern
|
||||
const { data: insData, error: insError } = await adminClient
|
||||
.from('device_verification_codes')
|
||||
.insert({ user_id: userId, code, device_hash: deviceHash, expires_at: expiresAt })
|
||||
.select()
|
||||
|
||||
if (insError) {
|
||||
console.error("2FA CODE INSERT ERROR:", insError)
|
||||
} else {
|
||||
console.log("2FA CODE INSERTED:", code, insData)
|
||||
}
|
||||
|
||||
// Mail senden
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Ihr Sicherheitscode – CASPOS Shop',
|
||||
text: `Ihr Sicherheitscode lautet: ${code}\n\nDieser Code ist 15 Minuten gültig.\n\nFalls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.`,
|
||||
html: get2FAEmailHtml(code),
|
||||
})
|
||||
}
|
||||
|
||||
/** Public server action: resend 2FA code */
|
||||
export async function resend2FACode(userId: string, deviceHash: string) {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
|
||||
const email = authUser?.user?.email
|
||||
|
||||
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
|
||||
|
||||
await send2FACodeInternal(userId, deviceHash, email)
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Resend 2FA error:', err)
|
||||
return { success: false, error: 'Fehler beim erneuten Senden des Codes.' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify 2FA code and register device */
|
||||
export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const cleanCode = code.trim()
|
||||
|
||||
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
|
||||
console.log("VERIFYING 2FA: userId =", userId, "code =", cleanCode)
|
||||
const { data: codeEntries, error: codeError } = await adminClient
|
||||
.from('device_verification_codes')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('code', cleanCode)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
|
||||
console.log("VERIFY 2FA DB RESULT: entry =", codeEntry, "error =", codeError)
|
||||
|
||||
if (codeError || !codeEntry) {
|
||||
// Doppelklick- & Race-Condition Schutz: Prüfen ob das Gerät eben bereits verifiziert wurde
|
||||
const { data: knownDev } = await adminClient
|
||||
.from('known_devices')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
|
||||
if (knownDev && knownDev.length > 0) {
|
||||
const { data: userData } = await adminClient
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
return { success: true, role: userData?.role || 'partner' }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Ungültiger Sicherheitscode.' }
|
||||
}
|
||||
|
||||
// Ablauf prüfen
|
||||
if (new Date(codeEntry.expires_at) < new Date()) {
|
||||
// Code abgelaufen -> löschen
|
||||
await adminClient
|
||||
.from('device_verification_codes')
|
||||
.delete()
|
||||
.eq('id', codeEntry.id)
|
||||
return { success: false, error: 'Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.' }
|
||||
}
|
||||
|
||||
// Gerät registrieren
|
||||
const headersList = await headers()
|
||||
const userAgent = headersList.get('user-agent') || ''
|
||||
const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || ''
|
||||
const targetDeviceHash = deviceHash || codeEntry.device_hash || 'default-device'
|
||||
|
||||
await adminClient
|
||||
.from('known_devices')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
device_hash: targetDeviceHash,
|
||||
user_agent: userAgent,
|
||||
ip_address: ip,
|
||||
verified_at: new Date().toISOString(),
|
||||
}, { onConflict: 'user_id,device_hash' })
|
||||
|
||||
// Verbrauchte Codes für diesen User löschen
|
||||
await adminClient
|
||||
.from('device_verification_codes')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
|
||||
// Andere Sessions beenden
|
||||
const supabase = await createClient()
|
||||
await supabase.auth.signOut({ scope: 'others' })
|
||||
|
||||
// Rolle zurückgeben
|
||||
const { data: userData } = await adminClient
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
return { success: true, role: userData?.role || 'partner' }
|
||||
} catch (err: any) {
|
||||
console.error('Verify 2FA error:', err)
|
||||
return { success: false, error: 'Fehler bei der Code-Überprüfung.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(email: string) {
|
||||
try {
|
||||
const admin = createAdminClient()
|
||||
|
||||
122
shop/lib/actions/branding.ts
Normal file
122
shop/lib/actions/branding.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
export async function getBrandingSettings(): Promise<BrandingSettings> {
|
||||
try {
|
||||
const admin = createAdminClient()
|
||||
const { data } = await admin
|
||||
.from('settings')
|
||||
.select('*')
|
||||
.eq('id', 'branding')
|
||||
.maybeSingle()
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
companyName: '',
|
||||
logoUrl: '',
|
||||
developerFooter: 'CASPOS Shop',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
companyName: data.company_name || '',
|
||||
logoUrl: data.logo_url || '',
|
||||
developerFooter: data.developer_footer || 'B2B Shop made by hephex',
|
||||
street: data.street || '',
|
||||
zip: data.zip || '',
|
||||
city: data.city || '',
|
||||
billingStreet: data.billing_street || '',
|
||||
billingZip: data.billing_zip || '',
|
||||
billingCity: data.billing_city || '',
|
||||
sameBillingAddress: data.same_billing_address !== false,
|
||||
colorScheme: data.color_scheme || 'modern_blue',
|
||||
primaryColor: data.primary_color || '#2563eb',
|
||||
accentColor: data.accent_color || '#38bdf8',
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load branding settings:', err)
|
||||
return {
|
||||
companyName: '',
|
||||
logoUrl: '',
|
||||
developerFooter: 'B2B Shop made by hephex',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBrandingSettings(
|
||||
settings: BrandingSettings
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (user) {
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!userData || userData.role !== 'admin') {
|
||||
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
|
||||
}
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
const { error } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'branding',
|
||||
company_name: settings.companyName,
|
||||
logo_url: settings.logoUrl,
|
||||
developer_footer: settings.developerFooter,
|
||||
street: settings.street,
|
||||
zip: settings.zip,
|
||||
city: settings.city,
|
||||
billing_street: settings.sameBillingAddress ? settings.street : settings.billingStreet,
|
||||
billing_zip: settings.sameBillingAddress ? settings.zip : settings.billingZip,
|
||||
billing_city: settings.sameBillingAddress ? settings.city : settings.billingCity,
|
||||
same_billing_address: settings.sameBillingAddress,
|
||||
color_scheme: settings.colorScheme,
|
||||
primary_color: settings.primaryColor,
|
||||
accent_color: settings.accentColor,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to save branding settings:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/admin/einstellungen')
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Exception in saveBrandingSettings:', err)
|
||||
return { success: false, error: err.message || 'Unerwarteter Fehler' }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,82 +1,489 @@
|
||||
interface EmailDetails {
|
||||
export interface OrderEmailProps {
|
||||
orderNumber: string
|
||||
status: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
formattedDate: string
|
||||
customerCompanyName: string
|
||||
totalDetailsText: string
|
||||
totalDetailsHtml: string
|
||||
billingModel?: string // e.g. "Kauf" | "Miete (monatlich)" | "Kauf / Miete"
|
||||
rejectionReason?: string
|
||||
siteUrl?: string
|
||||
items?: any[]
|
||||
partnerCompanyName?: string
|
||||
partnerUserName?: string
|
||||
partnerUserEmail?: string
|
||||
taxRate?: number
|
||||
oneTimeNet?: number
|
||||
monthlyNet?: number
|
||||
}
|
||||
|
||||
export function generateOrderEmailSubject(orderNumber: string, status: string): string {
|
||||
const formattedOrderNumber = (orderNumber || '').replace(/^BE-/, 'AE-')
|
||||
if (status === 'approved' || status === 'active' || status === 'completed') {
|
||||
return `Auftragsbestätigung: Anfrage #${formattedOrderNumber} freigegeben`
|
||||
}
|
||||
if (status === 'rejected' || status === 'cancelled') {
|
||||
return `Status-Update: Anfrage #${formattedOrderNumber} abgelehnt`
|
||||
}
|
||||
return `Eingangsbestätigung: Anfrage #${formattedOrderNumber} eingegangen`
|
||||
}
|
||||
|
||||
export function generateOrderEmailHtml(props: OrderEmailProps): { text: string; html: string } {
|
||||
const formattedOrderNumber = (props.orderNumber || '').replace(/^BE-/, 'AE-')
|
||||
const siteUrl = props.siteUrl || process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
|
||||
const taxRate = props.taxRate ?? 19
|
||||
const orderPortalUrl = `${siteUrl}/my-orders`
|
||||
|
||||
// Status Callout configuration
|
||||
let calloutBg = '#f8fafc'
|
||||
let calloutBorder = '#cbd5e1'
|
||||
let calloutTitle = 'Status: Anfrage eingegangen und in Prüfung'
|
||||
let calloutText = 'Ihre Anfrage ist erfolgreich in unserem System eingegangen und wird derzeit vom Support geprüft.'
|
||||
let statusBadgeColor = '#2563eb'
|
||||
|
||||
if (props.status === 'approved' || props.status === 'active' || props.status === 'completed') {
|
||||
calloutBg = '#f0fdf4'
|
||||
calloutBorder = '#86efac'
|
||||
calloutTitle = 'Status: Freigegeben / Aktiv'
|
||||
calloutText = 'Ihre Auftragsbestätigung / Rechnung finden Sie als PDF im Anhang dieser E-Mail.'
|
||||
statusBadgeColor = '#16a34a'
|
||||
} else if (props.status === 'rejected' || props.status === 'cancelled') {
|
||||
calloutBg = '#fef2f2'
|
||||
calloutBorder = '#fca5a5'
|
||||
calloutTitle = 'Status: Anfrage abgelehnt'
|
||||
calloutText = props.rejectionReason
|
||||
? `Begründung: "${props.rejectionReason}"`
|
||||
: 'Ihre Anfrage wurde vom Support geprüft und konnte leider nicht freigegeben werden.'
|
||||
statusBadgeColor = '#dc2626'
|
||||
}
|
||||
|
||||
// Calculate prices if items provided
|
||||
let oneTimeNet = props.oneTimeNet ?? 0
|
||||
let monthlyNet = props.monthlyNet ?? 0
|
||||
const items = props.items || []
|
||||
|
||||
if (items.length > 0 && props.oneTimeNet === undefined && props.monthlyNet === undefined) {
|
||||
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)
|
||||
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 formatEuro = (val: number) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
|
||||
// Determine Billing Model Label
|
||||
let billingModelLabel = props.billingModel
|
||||
if (!billingModelLabel) {
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
billingModelLabel = 'Kauf & Miete'
|
||||
} else if (monthlyNet > 0) {
|
||||
billingModelLabel = 'Miete (monatlich)'
|
||||
} else {
|
||||
billingModelLabel = 'Kauf (einmalig)'
|
||||
}
|
||||
}
|
||||
|
||||
// Group items by Device
|
||||
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)
|
||||
})
|
||||
|
||||
// Build Text Breakdown
|
||||
let itemsBreakdownText = ''
|
||||
if (items.length > 0) {
|
||||
itemsBreakdownText += '\nKassen-Aufstellung:\n'
|
||||
Object.entries(groupedItems).forEach(([devName, devItems]) => {
|
||||
itemsBreakdownText += `\n[ ${devName} ]\n`
|
||||
devItems.forEach((item: any) => {
|
||||
itemsBreakdownText += ` - ${item.product_name} (${item.category_name || 'Basis'}): ${formatEuro(item.base_price || 0)} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}\n`
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1
|
||||
const price = mod.total_price ?? (mod.price * qty)
|
||||
itemsBreakdownText += ` + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${formatEuro(price)} mtl.\n`
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let totalsText = ''
|
||||
if (oneTimeNet > 0) {
|
||||
totalsText += `\nEinmalige Beträge:\n- Netto: ${formatEuro(oneTimeNet)}\n- zzgl. ${taxRate}% MwSt: ${formatEuro(oneTimeTax)}\n- Brutto Gesamt: ${formatEuro(oneTimeGross)}\n`
|
||||
}
|
||||
if (monthlyNet > 0) {
|
||||
totalsText += `\nMonatliche Beträge:\n- Netto: ${formatEuro(monthlyNet)} / mtl.\n- zzgl. ${taxRate}% MwSt: ${formatEuro(monthlyTax)} / mtl.\n- Brutto Gesamt: ${formatEuro(monthlyGross)} / mtl.\n`
|
||||
}
|
||||
|
||||
let partnerPlainText = ''
|
||||
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
|
||||
partnerPlainText = `\nPartner / Betreuer:\n- Firma: ${props.partnerCompanyName || '-'}\n- Ansprechpartner: ${props.partnerUserName || '-'}\n- E-Mail: ${props.partnerUserEmail || '-'}\n`
|
||||
}
|
||||
|
||||
const text = `CASPOS B2B Portal\nAnfrage #${formattedOrderNumber}\n\n${calloutTitle}\n${calloutText}\n\nAuftragsdetails:\n- Endkunde: ${props.customerCompanyName}\n- Datum: ${props.formattedDate}\n- Abrechnung: ${billingModelLabel}\n${partnerPlainText}${itemsBreakdownText}${totalsText}\n\nAnfrage im Portal ansehen: ${orderPortalUrl}\n\nCASPOS Computerabrechnungssysteme GmbH\nAlte Bundesstraße 16 · 76846 Hauenstein\nAmtsgericht Zweibrücken HRB 12345\nAutomatische Systembenachrichtigung.`
|
||||
|
||||
// Build HTML Items Rows
|
||||
let itemsHtmlRows = ''
|
||||
if (items.length > 0) {
|
||||
Object.entries(groupedItems).forEach(([devName, devItems]) => {
|
||||
itemsHtmlRows += `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 10px 14px; background-color: #f1f5f9; font-weight: 600; font-size: 13px; color: #1e293b; border-bottom: 1px solid #e2e8f0;">
|
||||
${devName === 'Zusatzleistung' ? 'Backoffice / Zusatzleistung' : `Kassengerät: ${devName}`}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
devItems.forEach((item: any) => {
|
||||
itemsHtmlRows += `
|
||||
<tr>
|
||||
<td style="padding: 8px 14px; font-size: 13px; color: #334155; font-weight: 500; border-bottom: 1px solid #f1f5f9;">
|
||||
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name || 'Basis'})</span>
|
||||
</td>
|
||||
<td style="padding: 8px 14px; text-align: right; font-size: 13px; font-weight: 600; color: #0f172a; border-bottom: 1px solid #f1f5f9;">
|
||||
${formatEuro(item.base_price || 0)} <span style="font-size: 11px; color: #64748b; font-weight: normal;">${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1
|
||||
const price = mod.total_price ?? (mod.price * qty)
|
||||
itemsHtmlRows += `
|
||||
<tr>
|
||||
<td style="padding: 4px 14px 4px 28px; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
|
||||
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 4px 14px; text-align: right; color: #64748b; font-size: 12px; border-bottom: 1px solid #f8fafc;">
|
||||
+${formatEuro(price)} <span style="font-size: 10px;">mtl.</span>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Build HTML Partner Section
|
||||
let partnerHtml = ''
|
||||
if (props.partnerCompanyName || props.partnerUserName || props.partnerUserEmail) {
|
||||
partnerHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 12px 0 4px 0; border-top: 1px solid #e2e8f0; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b;">Partner & Betreuung:</td>
|
||||
</tr>
|
||||
${props.partnerCompanyName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Firma:</td><td style="padding: 3px 0; font-size: 13px; font-weight: 500; color: #0f172a; text-align: right;">${props.partnerCompanyName}</td></tr>` : ''}
|
||||
${props.partnerUserName ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">Ansprechpartner:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserName}</td></tr>` : ''}
|
||||
${props.partnerUserEmail ? `<tr><td style="padding: 3px 0; font-size: 13px; color: #64748b;">E-Mail:</td><td style="padding: 3px 0; font-size: 13px; color: #0f172a; text-align: right;">${props.partnerUserEmail}</td></tr>` : ''}
|
||||
`
|
||||
}
|
||||
|
||||
// Totals Section HTML
|
||||
let totalsHtml = ''
|
||||
if (oneTimeNet > 0 || monthlyNet > 0) {
|
||||
totalsHtml = `
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 16px; border-top: 2px solid #e2e8f0; padding-top: 12px; font-size: 13px; color: #334155;">
|
||||
${oneTimeNet > 0 ? `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em;">Einmalige Beträge:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
|
||||
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(oneTimeNet)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(oneTimeTax)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0 10px 0; font-weight: 600; color: #0f172a;">Gesamt einmalig (brutto):</td>
|
||||
<td style="padding: 4px 0 10px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(oneTimeGross)}</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
${monthlyNet > 0 ? `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: ${oneTimeNet > 0 ? '10px' : '4px'} 0 4px 0; font-weight: 600; font-size: 11px; text-transform: uppercase; color: #64748b; letter-spacing: 0.05em; ${oneTimeNet > 0 ? 'border-top: 1px dashed #e2e8f0;' : ''}">Monatlich wiederkehrend:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 0; color: #64748b;">Netto-Zwischensumme:</td>
|
||||
<td style="padding: 2px 0; text-align: right; font-weight: 500; color: #0f172a;">${formatEuro(monthlyNet)} / mtl.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 2px 0; color: #64748b;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 2px 0; text-align: right; color: #64748b;">${formatEuro(monthlyTax)} / mtl.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: 600; color: #0f172a;">Gesamt monatlich (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: 700; color: #0f172a; font-size: 14px;">${formatEuro(monthlyGross)} / mtl.</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CASPOS B2B Portal - #${formattedOrderNumber}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; color: #0f172a;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; padding: 24px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Main Card (max-width 600px) -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);">
|
||||
|
||||
<!-- Header Banner -->
|
||||
<tr>
|
||||
<td style="background-color: #0f172a; padding: 24px; text-align: left;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #ffffff; letter-spacing: -0.02em; text-transform: uppercase;">CASPOS B2B Portal</div>
|
||||
<div style="font-size: 12px; color: #94a3b8; margin-top: 2px;">Die Kasse · Fachhandelsportal</div>
|
||||
</td>
|
||||
<td align="right" style="vertical-align: middle;">
|
||||
<span style="font-family: monospace, Courier, monospace; font-size: 14px; font-weight: 600; color: #38bdf8; background-color: #1e293b; padding: 6px 10px; border-radius: 4px; border: 1px solid #334155;">
|
||||
#${formattedOrderNumber}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body Content -->
|
||||
<tr>
|
||||
<td style="padding: 24px;">
|
||||
|
||||
<!-- Status Callout -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: ${calloutBg}; border-left: 4px solid ${statusBadgeColor}; border-top: 1px solid ${calloutBorder}; border-right: 1px solid ${calloutBorder}; border-bottom: 1px solid ${calloutBorder}; border-radius: 4px; margin-bottom: 24px;">
|
||||
<tr>
|
||||
<td style="padding: 14px 16px;">
|
||||
<div style="font-weight: 700; font-size: 14px; color: #0f172a; margin-bottom: 4px;">${calloutTitle}</div>
|
||||
<div style="font-size: 13px; color: #334155; line-height: 1.5;">${calloutText}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Order & Customer Meta Table -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin-bottom: 24px;">
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="font-size: 13px;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #64748b; width: 140px;">Endkunde:</td>
|
||||
<td style="padding: 4px 0; font-weight: 600; color: #0f172a; text-align: right;">${props.customerCompanyName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #64748b;">Bestelldatum:</td>
|
||||
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${props.formattedDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #64748b;">Abrechnungsmodell:</td>
|
||||
<td style="padding: 4px 0; font-weight: 500; color: #0f172a; text-align: right;">${billingModelLabel}</td>
|
||||
</tr>
|
||||
${partnerHtml}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Hardware / Modules Breakdown -->
|
||||
${items.length > 0 ? `
|
||||
<div style="font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; margin-bottom: 8px;">Kassen-Aufstellung</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden; margin-bottom: 20px;">
|
||||
${itemsHtmlRows}
|
||||
</table>
|
||||
` : ''}
|
||||
|
||||
<!-- Totals -->
|
||||
${totalsHtml}
|
||||
|
||||
<!-- Primary Action Button -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin-top: 32px; margin-bottom: 8px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="${orderPortalUrl}" target="_blank" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 600; padding: 12px 28px; border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">
|
||||
[ Anfrage im Portal ansehen ]
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f8fafc; border-top: 1px solid #e2e8f0; padding: 20px 24px; text-align: center; font-size: 12px; color: #64748b; line-height: 1.5;">
|
||||
<p style="margin: 0; font-weight: 600; color: #475569;">CASPOS Computerabrechnungssysteme GmbH</p>
|
||||
<p style="margin: 2px 0 0 0;">Alte Bundesstraße 16 · 76846 Hauenstein · Amtsgericht Zweibrücken HRB 12345</p>
|
||||
<p style="margin: 8px 0 0 0; font-size: 11px; color: #94a3b8;">Dies ist eine automatische Transaktions-E-Mail des CASPOS B2B Portals.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
// Backwards-compatible Wrappers
|
||||
export function getOrderEmailTemplate(
|
||||
details: EmailDetails,
|
||||
details: {
|
||||
orderNumber: string
|
||||
formattedDate: string
|
||||
customerCompanyName: string
|
||||
totalDetailsText?: string
|
||||
totalDetailsHtml?: string
|
||||
itemsDetailsText?: string
|
||||
itemsDetailsHtml?: string
|
||||
partnerCompanyName?: string
|
||||
partnerUserName?: string
|
||||
partnerUserEmail?: string
|
||||
items?: any[]
|
||||
taxRate?: number
|
||||
oneTimeNet?: number
|
||||
monthlyNet?: number
|
||||
billingModel?: string
|
||||
},
|
||||
siteUrl: string,
|
||||
isUpdate: boolean = false
|
||||
isUpdate: boolean = false,
|
||||
_primaryColor?: string
|
||||
) {
|
||||
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten'
|
||||
const intro = isUpdate
|
||||
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
|
||||
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
|
||||
|
||||
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.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;">
|
||||
<h2 style="color: #0f172a; margin-bottom: 16px;">${title}</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p>
|
||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
|
||||
<td style="padding: 4px 0;">${details.orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
|
||||
<td style="padding: 4px 0;">${details.formattedDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
|
||||
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
|
||||
</tr>
|
||||
${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; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
return { text, html }
|
||||
return generateOrderEmailHtml({
|
||||
orderNumber: details.orderNumber,
|
||||
status: isUpdate ? 'in_review' : 'pending',
|
||||
formattedDate: details.formattedDate,
|
||||
customerCompanyName: details.customerCompanyName,
|
||||
billingModel: details.billingModel,
|
||||
siteUrl,
|
||||
items: details.items,
|
||||
partnerCompanyName: details.partnerCompanyName,
|
||||
partnerUserName: details.partnerUserName,
|
||||
partnerUserEmail: details.partnerUserEmail,
|
||||
taxRate: details.taxRate,
|
||||
oneTimeNet: details.oneTimeNet,
|
||||
monthlyNet: details.monthlyNet,
|
||||
})
|
||||
}
|
||||
|
||||
export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) {
|
||||
const text = `Hallo,\n\nder Status deiner Anfrage ${orderNumber} hat sich geändert.\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nDein CASPOS Team`
|
||||
export function getStatusEmailTemplate(
|
||||
orderNumber: string,
|
||||
_oldLabel: string,
|
||||
_newLabel: string,
|
||||
statusKey?: string,
|
||||
rejectionReason?: string,
|
||||
extraDetails?: {
|
||||
customerCompanyName?: string
|
||||
formattedDate?: string
|
||||
billingModel?: string
|
||||
items?: any[]
|
||||
partnerCompanyName?: string
|
||||
partnerUserName?: string
|
||||
partnerUserEmail?: string
|
||||
taxRate?: number
|
||||
oneTimeNet?: number
|
||||
monthlyNet?: number
|
||||
}
|
||||
) {
|
||||
const status = (statusKey || 'pending') as any
|
||||
return generateOrderEmailHtml({
|
||||
orderNumber,
|
||||
status,
|
||||
formattedDate: extraDetails?.formattedDate || new Date().toLocaleDateString('de-DE'),
|
||||
customerCompanyName: extraDetails?.customerCompanyName || 'Endkunde',
|
||||
billingModel: extraDetails?.billingModel,
|
||||
rejectionReason,
|
||||
items: extraDetails?.items,
|
||||
partnerCompanyName: extraDetails?.partnerCompanyName,
|
||||
partnerUserName: extraDetails?.partnerUserName,
|
||||
partnerUserEmail: extraDetails?.partnerUserEmail,
|
||||
taxRate: extraDetails?.taxRate,
|
||||
oneTimeNet: extraDetails?.oneTimeNet,
|
||||
monthlyNet: extraDetails?.monthlyNet,
|
||||
})
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<h2 style="color: #0f172a; margin-bottom: 16px;">Statusänderung</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status deiner Anfrage <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
|
||||
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Vorher:</td>
|
||||
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Aktuell:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||
</div>
|
||||
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>
|
||||
`
|
||||
|
||||
return { text, html }
|
||||
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}): ${Number(item.base_price || 0).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;">
|
||||
${Number(item.base_price || 0).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})` : ''}: +${Number(price || 0).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;">
|
||||
+${Number(price || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
text: itemsDetailsText + '\n',
|
||||
html: itemsDetailsHtml
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function createEndCustomer(formData: EndCustomerFormData, companyId
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
|
||||
const resolvedCompanyId = dbUser?.company_id || companyId
|
||||
|
||||
if (!isAdmin && !resolvedCompanyId) {
|
||||
|
||||
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,42 @@ 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 {
|
||||
generateOrderEmailHtml,
|
||||
generateOrderEmailSubject,
|
||||
getOrderEmailTemplate,
|
||||
getStatusEmailTemplate,
|
||||
buildEmailItemsSection,
|
||||
} from '@/lib/actions/email-templates'
|
||||
|
||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchOrderPartnerInfo(adminClient: any, companyId: string | null, userId: string | null) {
|
||||
let partnerCompanyName = ''
|
||||
let partnerUserName = ''
|
||||
let partnerUserEmail = ''
|
||||
|
||||
if (companyId) {
|
||||
const { data: comp } = await adminClient.from('companies').select('name').eq('id', companyId).single()
|
||||
if (comp) partnerCompanyName = comp.name
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const { data: dbUser } = await adminClient.from('users').select('first_name, last_name, email, role').eq('id', userId).single()
|
||||
if (dbUser) {
|
||||
partnerUserName = [dbUser.first_name, dbUser.last_name].filter(Boolean).join(' ')
|
||||
partnerUserEmail = dbUser.email || ''
|
||||
if (!partnerCompanyName && (dbUser.role === 'admin' || dbUser.role === 'verwaltung')) {
|
||||
partnerCompanyName = 'Administrator / Direktbestellung'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { partnerCompanyName, partnerUserName, partnerUserEmail }
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
||||
@@ -66,15 +98,31 @@ export async function submitOrder(params: {
|
||||
// Check if user is assigned to a company or is admin
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.select('role, company_id, first_name, last_name, email')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin'
|
||||
const isAdminUser = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
throw new Error('Sie müssen einer Firma zugewiesen sein, um eine Anfrage zu erstellen.')
|
||||
}
|
||||
|
||||
let partnerCompanyName = ''
|
||||
if (dbUser?.company_id) {
|
||||
const { data: comp } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', dbUser.company_id)
|
||||
.single()
|
||||
if (comp) {
|
||||
partnerCompanyName = comp.name
|
||||
}
|
||||
} else if (isAdminUser) {
|
||||
partnerCompanyName = 'Administrator / Direktbestellung'
|
||||
}
|
||||
const partnerUserName = dbUser ? [dbUser.first_name, dbUser.last_name].filter(Boolean).join(' ') : ''
|
||||
const partnerUserEmail = dbUser?.email || user.email
|
||||
|
||||
// Fetch catalog directly from DB to prevent client tampering
|
||||
const dbProducts = await getProducts()
|
||||
const dbCategories = await getCategories()
|
||||
@@ -146,6 +194,9 @@ export async function submitOrder(params: {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
partnerCompanyName,
|
||||
partnerUserName,
|
||||
partnerUserEmail,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -256,17 +307,25 @@ export async function submitOrder(params: {
|
||||
`
|
||||
}
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
const emailTemplate = generateOrderEmailHtml({
|
||||
orderNumber,
|
||||
status: 'pending',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
|
||||
items,
|
||||
partnerCompanyName,
|
||||
partnerUserName,
|
||||
partnerUserEmail,
|
||||
taxRate,
|
||||
oneTimeNet,
|
||||
monthlyNet,
|
||||
})
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(orderNumber, 'pending')
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Anfragebestätigung ${orderNumber}`,
|
||||
subject: mailSubject,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
@@ -277,6 +336,37 @@ export async function submitOrder(params: {
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Notify all admins
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: admins } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.in('role', ['admin', 'verwaltung'])
|
||||
if (admins) {
|
||||
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
|
||||
for (const adminEmail of adminEmails) {
|
||||
if (adminEmail !== user.email) {
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: `[Admin-Kopie] ${mailSubject}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (adminMailError) {
|
||||
console.error('Failed to notify admins of new order:', adminMailError)
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send order confirmation mail:', mailError)
|
||||
}
|
||||
@@ -292,14 +382,15 @@ export async function submitOrder(params: {
|
||||
|
||||
export async function updateOrderStatus(
|
||||
orderId: string,
|
||||
newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
newStatus: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
) {
|
||||
const admin = createAdminClient()
|
||||
const supabase = await createClient()
|
||||
|
||||
// 1. Fetch current order
|
||||
// 1. Fetch current order with full details (snapshots)
|
||||
const { data: order, error: fetchError } = await admin
|
||||
.from('orders')
|
||||
.select('id, user_id, order_number, status')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single()
|
||||
|
||||
@@ -310,7 +401,7 @@ export async function updateOrderStatus(
|
||||
const oldStatus = order.status
|
||||
|
||||
if (oldStatus === newStatus) {
|
||||
return order
|
||||
return order as Order
|
||||
}
|
||||
|
||||
// 2. Update order status
|
||||
@@ -325,31 +416,112 @@ export async function updateOrderStatus(
|
||||
throw new Error(`Fehler beim Aktualisieren des Status: ${updateError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
// 3. Send email to the retailer (händler)
|
||||
const partnerInfo = await fetchOrderPartnerInfo(admin, updatedOrder.company_id, updatedOrder.user_id)
|
||||
|
||||
// 3. Wenn Status auf 'active' oder 'approved' wechselt, generiere PDF und lade es hoch (pdf-invoice-generator)
|
||||
let attachmentBuffer: Buffer | null = null
|
||||
if (newStatus === 'active' || newStatus === 'approved') {
|
||||
try {
|
||||
const customerSnapshot = updatedOrder.customer_data
|
||||
const orderSnapshot = updatedOrder.order_data
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order: updatedOrder,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
partnerCompanyName: partnerInfo.partnerCompanyName,
|
||||
partnerUserName: partnerInfo.partnerUserName,
|
||||
partnerUserEmail: partnerInfo.partnerUserEmail,
|
||||
})
|
||||
)
|
||||
attachmentBuffer = buffer
|
||||
|
||||
// PDF in Supabase Storage hochladen
|
||||
const fileName = `ab_${updatedOrder.id}.pdf`
|
||||
const { error: uploadError } = await admin
|
||||
.storage
|
||||
.from('invoices')
|
||||
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
|
||||
|
||||
if (uploadError) {
|
||||
console.error('PDF Upload Error:', uploadError)
|
||||
} else {
|
||||
await admin.from('orders').update({ pdf_url: fileName }).eq('id', updatedOrder.id)
|
||||
updatedOrder.pdf_url = fileName
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.error('PDF Generation Error on status transition:', pdfError)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. E-Mail mit SMTP an den Besteller senden (b2b-mail-dispatcher)
|
||||
if (order.user_id) {
|
||||
try {
|
||||
const { data: { user }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
||||
|
||||
if (!userError && user && user.email) {
|
||||
const orderNumber = order.order_number || order.id.slice(0, 8)
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
||||
const newLabel = statusLabelMap[newStatus] || newStatus
|
||||
const customerSnapshot = updatedOrder.customer_data || {}
|
||||
const orderSnapshot = updatedOrder.order_data || {}
|
||||
const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
|
||||
|
||||
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel)
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Statusänderung Ihrer Anfrage ${orderNumber}`,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html
|
||||
const statusEmail = generateOrderEmailHtml({
|
||||
orderNumber,
|
||||
status: newStatus,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
|
||||
items: orderSnapshot.items || [],
|
||||
partnerCompanyName: partnerInfo.partnerCompanyName,
|
||||
partnerUserName: partnerInfo.partnerUserName,
|
||||
partnerUserEmail: partnerInfo.partnerUserEmail,
|
||||
taxRate: orderSnapshot.tax_rate ?? 19,
|
||||
})
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(orderNumber, newStatus)
|
||||
|
||||
const mailOptions: any = {
|
||||
to: user.email,
|
||||
subject: mailSubject,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html,
|
||||
}
|
||||
|
||||
// Bei Aktivierung oder Freigabe hänge die PDF an
|
||||
if ((newStatus === 'active' || newStatus === 'approved') && attachmentBuffer) {
|
||||
mailOptions.attachments = [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
|
||||
content: attachmentBuffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
await sendMail(mailOptions)
|
||||
|
||||
// Notify admins
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: admins } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.in('role', ['admin', 'verwaltung'])
|
||||
if (admins) {
|
||||
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
|
||||
for (const adminEmail of adminEmails) {
|
||||
if (adminEmail !== user.email) {
|
||||
await sendMail({
|
||||
...mailOptions,
|
||||
to: adminEmail,
|
||||
subject: `[Admin-Kopie] ${mailSubject}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (adminMailError) {
|
||||
console.error('Failed to notify admins of status update:', adminMailError)
|
||||
}
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send status update email:', mailError)
|
||||
@@ -361,6 +533,186 @@ export async function updateOrderStatus(
|
||||
return updatedOrder as Order
|
||||
}
|
||||
|
||||
export async function updateOrderPayload(
|
||||
orderId: string,
|
||||
params: {
|
||||
orderSnapshot: Partial<any>;
|
||||
}
|
||||
) {
|
||||
const admin = createAdminClient()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Authentifizierung und Admin-Prüfung
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { data: dbUser } = await admin
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (dbUser?.role !== 'admin') {
|
||||
throw new Error('Nur Administratoren können Bestellanfragen anpassen.')
|
||||
}
|
||||
|
||||
// Aktuelle Bestellung abrufen
|
||||
const { data: order, error: fetchError } = await admin
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !order) {
|
||||
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
if (order.status !== 'in_review') {
|
||||
throw new Error('Bestellungen können nur im Status "in_review" angepasst werden.')
|
||||
}
|
||||
|
||||
// Neue Snapshot-Daten mergen
|
||||
const updatedSnapshot = {
|
||||
...order.order_data,
|
||||
...params.orderSnapshot
|
||||
}
|
||||
|
||||
const { data: updatedOrder, error: updateError } = await admin
|
||||
.from('orders')
|
||||
.update({
|
||||
order_data: updatedSnapshot,
|
||||
total_price: updatedSnapshot.total || order.total_price
|
||||
})
|
||||
.eq('id', orderId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updatedOrder) {
|
||||
throw new Error(`Fehler beim Aktualisieren der Bestelldaten: ${updateError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
revalidatePath('/admin/orders')
|
||||
revalidatePath('/my-orders')
|
||||
return updatedOrder as Order
|
||||
}
|
||||
|
||||
export async function rejectOrder(
|
||||
orderId: string,
|
||||
reason: string
|
||||
) {
|
||||
const admin = createAdminClient()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Authentifizierung und Admin-Prüfung
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { data: dbUser } = await admin
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (dbUser?.role !== 'admin') {
|
||||
throw new Error('Nur Administratoren können Bestellungen ablehnen.')
|
||||
}
|
||||
|
||||
const { data: order, error: fetchError } = await admin
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !order) {
|
||||
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
const updatedSnapshot = {
|
||||
...order.order_data,
|
||||
rejection_reason: reason
|
||||
}
|
||||
|
||||
const { data: updatedOrder, error: updateError } = await admin
|
||||
.from('orders')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
order_data: updatedSnapshot
|
||||
})
|
||||
.eq('id', orderId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updatedOrder) {
|
||||
throw new Error(`Fehler beim Ablehnen der Bestellung: ${updateError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
// Ablehnungs-Mail senden
|
||||
if (order.user_id) {
|
||||
try {
|
||||
const { data: { user: orderUser }, error: userError } = await admin.auth.admin.getUserById(order.user_id)
|
||||
if (!userError && orderUser && orderUser.email) {
|
||||
const orderNumber = order.order_number || order.id.slice(0, 8)
|
||||
const partnerInfo = await fetchOrderPartnerInfo(admin, updatedOrder.company_id, updatedOrder.user_id)
|
||||
const customerSnapshot = updatedOrder.customer_data || {}
|
||||
const orderSnapshot = updatedOrder.order_data || {}
|
||||
const formattedDate = new Date(updatedOrder.created_at || Date.now()).toLocaleDateString('de-DE')
|
||||
|
||||
const statusEmail = generateOrderEmailHtml({
|
||||
orderNumber,
|
||||
status: 'rejected',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name || 'Endkunde',
|
||||
rejectionReason: reason,
|
||||
items: orderSnapshot.items || [],
|
||||
partnerCompanyName: partnerInfo.partnerCompanyName,
|
||||
partnerUserName: partnerInfo.partnerUserName,
|
||||
partnerUserEmail: partnerInfo.partnerUserEmail,
|
||||
taxRate: orderSnapshot.tax_rate ?? 19,
|
||||
})
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(orderNumber, 'rejected')
|
||||
|
||||
await sendMail({
|
||||
to: orderUser.email,
|
||||
subject: mailSubject,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html
|
||||
})
|
||||
|
||||
// Notify admins
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: admins } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.in('role', ['admin', 'verwaltung'])
|
||||
if (admins) {
|
||||
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
|
||||
for (const adminEmail of adminEmails) {
|
||||
if (adminEmail !== orderUser.email) {
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: `[Admin-Kopie] ${mailSubject}`,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (adminMailError) {
|
||||
console.error('Failed to notify admins of rejected order:', adminMailError)
|
||||
}
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send rejection email:', mailError)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/admin/orders')
|
||||
revalidatePath('/my-orders')
|
||||
return updatedOrder as Order
|
||||
}
|
||||
|
||||
export async function updateOrder(
|
||||
orderId: string,
|
||||
params: {
|
||||
@@ -398,7 +750,7 @@ export async function updateOrder(
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
|
||||
let isAuthorized = existingOrder.user_id === user.id
|
||||
|
||||
if (!isAuthorized && dbUser?.company_id && existingOrder.user_id) {
|
||||
@@ -459,6 +811,8 @@ export async function updateOrder(
|
||||
|
||||
if (orderError || !order) throw orderError || new Error('Fehler beim Aktualisieren der Bestellung.')
|
||||
|
||||
const partnerInfo = await fetchOrderPartnerInfo(admin, order.company_id, order.user_id)
|
||||
|
||||
// 3. PDF generieren und überschreiben
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
@@ -466,6 +820,9 @@ export async function updateOrder(
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
partnerCompanyName: partnerInfo.partnerCompanyName,
|
||||
partnerUserName: partnerInfo.partnerUserName,
|
||||
partnerUserEmail: partnerInfo.partnerUserEmail,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -577,12 +934,45 @@ export async function updateOrder(
|
||||
`
|
||||
}
|
||||
|
||||
// Load partner details for update email
|
||||
let partnerCompanyName = ''
|
||||
let partnerUserName = ''
|
||||
let partnerUserEmail = ''
|
||||
if (order?.user_id) {
|
||||
const { data: orderDbUser } = await supabase
|
||||
.from('users')
|
||||
.select('first_name, last_name, email, company_id')
|
||||
.eq('id', order.user_id)
|
||||
.single()
|
||||
if (orderDbUser) {
|
||||
partnerUserName = [orderDbUser.first_name, orderDbUser.last_name].filter(Boolean).join(' ')
|
||||
partnerUserEmail = orderDbUser.email || ''
|
||||
if (orderDbUser.company_id) {
|
||||
const { data: comp } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', orderDbUser.company_id)
|
||||
.single()
|
||||
if (comp) {
|
||||
partnerCompanyName = comp.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
partnerCompanyName,
|
||||
partnerUserName,
|
||||
partnerUserEmail
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
|
||||
|
||||
await sendMail({
|
||||
@@ -598,6 +988,37 @@ export async function updateOrder(
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Notify admins
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: admins } = await adminClient
|
||||
.from('users')
|
||||
.select('email')
|
||||
.in('role', ['admin', 'verwaltung'])
|
||||
if (admins) {
|
||||
const adminEmails = admins.map((a: any) => a.email).filter(Boolean)
|
||||
for (const adminEmail of adminEmails) {
|
||||
if (adminEmail !== orderUserEmail) {
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: `[Admin-Kopie] Anfrageänderung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (adminMailError) {
|
||||
console.error('Failed to notify admins of order update:', adminMailError)
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send order update confirmation mail:', mailError)
|
||||
}
|
||||
|
||||
288
shop/lib/actions/queries.ts
Normal file
288
shop/lib/actions/queries.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import type { Order, EndCustomer, EndCustomerWithOrders, OrderWithRegisterName, FlattenedDevice, EndCustomerWithDevices, OrderItem } from '@/lib/types'
|
||||
|
||||
/**
|
||||
* Holt alle Bestellanfragen.
|
||||
* RLS filtert automatisch auf die eigene Firma (bzw. lässt Admins alle sehen).
|
||||
*/
|
||||
export async function getCompanyOrders(): Promise<Order[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data as Order[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Endkunden.
|
||||
* RLS filtert automatisch auf die eigene Firma (bzw. lässt Admins alle sehen).
|
||||
*/
|
||||
export async function getCompanyEndCustomers(): Promise<EndCustomer[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.order('company_name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data as EndCustomer[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Endkunden inkl. ihrer Bestellungen/Kassen verschachtelt.
|
||||
* Extrahiert den Kassen-Namen aus order_data.items[0].device_name (Fallback "Kasse 1").
|
||||
* RLS filtert automatisch über get_auth_company_id().
|
||||
*/
|
||||
export async function getPartnerCustomersWithOrders(): Promise<EndCustomerWithOrders[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*, orders(*)')
|
||||
.order('company_name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map((customer: any) => {
|
||||
const rawOrders = customer.orders || []
|
||||
// Sort orders by created_at desc
|
||||
const sortedOrders = [...rawOrders].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
const orders: OrderWithRegisterName[] = sortedOrders.map((order: any) => {
|
||||
const items = order.order_data?.items || []
|
||||
const deviceName = items[0]?.device_name || 'Kasse 1'
|
||||
return {
|
||||
...order,
|
||||
register_name: deviceName,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...customer,
|
||||
orders,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt Kassen-Präfixe aus Produkt- und Modulnamen.
|
||||
* Entfernt Muster wie "Kasse1:", "kass2:", "kasse 1:" etc.
|
||||
*/
|
||||
function stripDevicePrefix(name: string): string {
|
||||
return name.replace(/^kasse?\s*\d*\s*:\s*/i, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt alle Endkunden mit geflatteteten Kassen (eine Kasse = ein FlattenedDevice).
|
||||
* Jede Order wird nach device_name in einzelne Kassen aufgespalten.
|
||||
* RLS filtert automatisch über get_auth_company_id().
|
||||
*/
|
||||
export async function getPartnerCustomersWithDevices(): Promise<EndCustomerWithDevices[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*, orders(*)')
|
||||
.order('company_name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map((customer: any) => {
|
||||
const rawOrders = customer.orders || []
|
||||
const sortedOrders = [...rawOrders].sort(
|
||||
(a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
const devices: FlattenedDevice[] = []
|
||||
|
||||
for (const order of sortedOrders) {
|
||||
const allItems: OrderItem[] = order.order_data?.items || []
|
||||
const totalOrderPrice: number = order.total_price || 0
|
||||
|
||||
// Gruppiere Items nach device_name
|
||||
const deviceGroups: Record<string, OrderItem[]> = {}
|
||||
for (const item of allItems) {
|
||||
const key = item.device_name || 'Kasse 1'
|
||||
if (!deviceGroups[key]) deviceGroups[key] = []
|
||||
deviceGroups[key].push(item)
|
||||
}
|
||||
|
||||
const deviceKeys = Object.keys(deviceGroups)
|
||||
|
||||
for (const deviceKey of deviceKeys) {
|
||||
const devItems = deviceGroups[deviceKey]
|
||||
|
||||
// Bereinige Produkt- und Modulnamen
|
||||
const cleanedItems: OrderItem[] = devItems.map(item => ({
|
||||
...item,
|
||||
product_name: stripDevicePrefix(item.product_name),
|
||||
selected_modules: (item.selected_modules || []).map(mod => ({
|
||||
...mod,
|
||||
module_name: stripDevicePrefix(mod.module_name),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Anteiliger Preis dieser Kasse (gleichmäßige Aufteilung)
|
||||
const devicePrice = deviceKeys.length > 1
|
||||
? totalOrderPrice / deviceKeys.length
|
||||
: totalOrderPrice
|
||||
|
||||
devices.push({
|
||||
orderId: order.id,
|
||||
orderNumber: order.order_number,
|
||||
orderStatus: order.status,
|
||||
pdfUrl: order.pdf_url,
|
||||
createdAt: order.created_at,
|
||||
deviceId: deviceKey,
|
||||
deviceName: deviceKey,
|
||||
items: cleanedItems,
|
||||
totalPrice: Math.round(devicePrice * 100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { orders: _orders, ...customerWithoutOrders } = customer
|
||||
return {
|
||||
...customerWithoutOrders,
|
||||
devices,
|
||||
} as EndCustomerWithDevices
|
||||
})
|
||||
}
|
||||
|
||||
export interface GetCustomersForWizardParams {
|
||||
partnerCompanyId?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface GetCustomersForWizardResult {
|
||||
customers: EndCustomer[]
|
||||
totalCount: number
|
||||
totalPages: number
|
||||
page: number
|
||||
pageSize: number
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Action für den Neubestellung-Wizard:
|
||||
* Holt Endkunden rollenbasiert mit Partner-Filter (für Admins) und Pagination.
|
||||
*/
|
||||
export async function getCustomersForWizard(
|
||||
params: GetCustomersForWizardParams = {}
|
||||
): Promise<GetCustomersForWizardResult> {
|
||||
const { partnerCompanyId, page = 1, pageSize = 10, search = '' } = params
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Nicht authentifiziert')
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = dbUser?.role === 'admin' || dbUser?.role === 'verwaltung'
|
||||
|
||||
let client = supabase
|
||||
if (isAdmin) {
|
||||
try {
|
||||
client = createAdminClient() as any
|
||||
} catch {
|
||||
client = supabase
|
||||
}
|
||||
}
|
||||
|
||||
let query = client
|
||||
.from('end_customers')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('is_anonymized', false)
|
||||
|
||||
if (isAdmin) {
|
||||
if (partnerCompanyId && partnerCompanyId !== 'all' && partnerCompanyId !== '') {
|
||||
query = query.eq('partner_id', partnerCompanyId)
|
||||
}
|
||||
} else {
|
||||
if (dbUser?.company_id) {
|
||||
query = query.eq('partner_id', dbUser.company_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (search && search.trim() !== '') {
|
||||
const term = `%${search.trim()}%`
|
||||
query = query.or(
|
||||
`company_name.ilike.${term},first_name.ilike.${term},last_name.ilike.${term},city.ilike.${term},zip.ilike.${term}`
|
||||
)
|
||||
}
|
||||
|
||||
query = query.order('company_name', { ascending: true })
|
||||
|
||||
const validPageSize = [10, 20, 50].includes(pageSize) ? pageSize : 10
|
||||
const validPage = Math.max(1, page)
|
||||
const from = (validPage - 1) * validPageSize
|
||||
const to = from + validPageSize - 1
|
||||
|
||||
query = query.range(from, to)
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const totalCount = count ?? 0
|
||||
const totalPages = Math.ceil(totalCount / validPageSize) || 1
|
||||
|
||||
return {
|
||||
customers: (data as EndCustomer[]) || [],
|
||||
totalCount,
|
||||
totalPages,
|
||||
page: validPage,
|
||||
pageSize: validPageSize,
|
||||
isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a license number is already taken by any active order.
|
||||
*/
|
||||
export async function isLicenseNumberTaken(licenseNumber: string, excludeOrderId?: string): Promise<boolean> {
|
||||
const normalized = licenseNumber?.trim().toUpperCase()
|
||||
if (!normalized) return false
|
||||
|
||||
const supabase = await createClient()
|
||||
let query = supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.contains('order_snapshot', { items: [{ license_number: normalized }] })
|
||||
|
||||
if (excludeOrderId) {
|
||||
query = query.neq('id', excludeOrderId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
console.error('Error checking license number:', error)
|
||||
return false
|
||||
}
|
||||
return !!(data && data.length > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
export type AdminSetupData = {
|
||||
email: string
|
||||
@@ -11,6 +12,19 @@ export type AdminSetupData = {
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export type BrandingSetupData = {
|
||||
street: string
|
||||
zip: string
|
||||
city: string
|
||||
billingStreet: string
|
||||
billingZip: string
|
||||
billingCity: string
|
||||
sameBillingAddress: boolean
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
}
|
||||
|
||||
export type SmtpSetupData = {
|
||||
host: string
|
||||
port: number
|
||||
@@ -32,26 +46,21 @@ export async function isSetupNeeded(): Promise<boolean> {
|
||||
perPage: 1
|
||||
})
|
||||
|
||||
if (authError) {
|
||||
console.error('Error checking auth users list:', authError)
|
||||
if (!authError && authData && authData.users && authData.users.length > 0) {
|
||||
// Mindestens ein Auth-Benutzer vorhanden -> Setup ist beendet!
|
||||
return false
|
||||
}
|
||||
|
||||
if (authData.users.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Check if any user exists in public.users table
|
||||
// 2. Zusatzprüfung public.users Tabelle
|
||||
const { count, error: dbError } = await admin
|
||||
.from('users')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.select('id', { count: 'exact' })
|
||||
|
||||
if (dbError) {
|
||||
console.error('Error checking users table status:', dbError)
|
||||
if (!dbError && typeof count === 'number' && count > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return count === 0
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Exception checking setup status:', e)
|
||||
return false
|
||||
@@ -60,14 +69,14 @@ export async function isSetupNeeded(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Completes the initial setup by creating the first admin user,
|
||||
* updating their role and profile, and storing SMTP settings.
|
||||
* updating their role and profile, and storing branding & SMTP settings.
|
||||
*/
|
||||
export async function completeSetup(
|
||||
adminData: AdminSetupData,
|
||||
brandingData: BrandingSetupData,
|
||||
smtpData: SmtpSetupData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// 1. Double check if setup is actually needed to prevent double runs
|
||||
const needed = await isSetupNeeded()
|
||||
if (!needed) {
|
||||
return { success: false, error: 'Setup bereits abgeschlossen.' }
|
||||
@@ -75,7 +84,7 @@ export async function completeSetup(
|
||||
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 2. Create user in Supabase Auth
|
||||
// 1. Create user in Supabase Auth
|
||||
const { data: authData, error: authError } = await admin.auth.admin.createUser({
|
||||
email: adminData.email,
|
||||
password: adminData.password,
|
||||
@@ -88,17 +97,20 @@ export async function completeSetup(
|
||||
|
||||
const userId = authData.user.id
|
||||
|
||||
// 3. Update user role to admin in public.users
|
||||
// 2. Set user role to admin in public.users using Service Role Client
|
||||
const { error: roleError } = await admin
|
||||
.from('users')
|
||||
.update({ role: 'admin' })
|
||||
.eq('id', userId)
|
||||
.upsert({
|
||||
id: userId,
|
||||
role: 'admin',
|
||||
}, { onConflict: 'id' })
|
||||
|
||||
if (roleError) {
|
||||
console.error('Error setting user role to admin:', roleError)
|
||||
return { success: false, error: `Fehler beim Zuweisen der Admin-Rolle: ${roleError.message}` }
|
||||
}
|
||||
|
||||
// 4. Update company and name details in public.profiles
|
||||
// 3. Update company and name details in public.profiles
|
||||
const { error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.update({
|
||||
@@ -113,22 +125,46 @@ export async function completeSetup(
|
||||
console.error('Error updating admin profile:', profileError)
|
||||
}
|
||||
|
||||
// 5. Store SMTP configuration in public.settings
|
||||
const { error: smtpError } = await admin
|
||||
// 4. Store Branding settings in public.settings (id = 'branding')
|
||||
const { error: brandingError } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'smtp',
|
||||
host: smtpData.host,
|
||||
port: smtpData.port,
|
||||
secure: smtpData.secure,
|
||||
user: smtpData.user,
|
||||
pass: smtpData.pass,
|
||||
id: 'branding',
|
||||
company_name: adminData.companyName,
|
||||
street: brandingData.street,
|
||||
zip: brandingData.zip,
|
||||
city: brandingData.city,
|
||||
billing_street: brandingData.sameBillingAddress ? brandingData.street : brandingData.billingStreet,
|
||||
billing_zip: brandingData.sameBillingAddress ? brandingData.zip : brandingData.billingZip,
|
||||
billing_city: brandingData.sameBillingAddress ? brandingData.city : brandingData.billingCity,
|
||||
same_billing_address: brandingData.sameBillingAddress,
|
||||
color_scheme: brandingData.colorScheme,
|
||||
primary_color: brandingData.primaryColor,
|
||||
accent_color: brandingData.accentColor,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (smtpError) {
|
||||
console.error('Error saving SMTP settings:', smtpError)
|
||||
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
|
||||
if (brandingError) {
|
||||
console.error('Error saving branding settings:', brandingError)
|
||||
}
|
||||
|
||||
// 5. Store SMTP configuration in public.settings (id = 'smtp')
|
||||
if (smtpData.host && smtpData.host.trim().length > 0) {
|
||||
const { error: smtpError } = await admin
|
||||
.from('settings')
|
||||
.upsert({
|
||||
id: 'smtp',
|
||||
host: smtpData.host,
|
||||
port: smtpData.port,
|
||||
secure: smtpData.secure,
|
||||
user: smtpData.user,
|
||||
pass: smtpData.pass,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (smtpError) {
|
||||
console.error('Error saving SMTP settings:', smtpError)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
@@ -138,3 +174,46 @@ export async function completeSetup(
|
||||
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSmtpConfig(
|
||||
smtpData: SmtpSetupData,
|
||||
recipient: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
if (!smtpData.host || !smtpData.user) {
|
||||
return { success: false, message: 'Bitte Host und Benutzername ausfüllen.' }
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpData.host,
|
||||
port: Number(smtpData.port) || 587,
|
||||
secure: smtpData.secure,
|
||||
auth: {
|
||||
user: smtpData.user,
|
||||
pass: smtpData.pass || '',
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
})
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: smtpData.user,
|
||||
to: recipient || smtpData.user,
|
||||
subject: 'CASPOS Setup Test-E-Mail',
|
||||
text: 'Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.',
|
||||
html: '<b>Diese Test-E-Mail wurde erfolgreich während des Webshop-Setups versendet.</b>',
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test-E-Mail erfolgreich gesendet an ${recipient || smtpData.user}! (ID: ${info.messageId})`,
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('SMTP test error:', err)
|
||||
return {
|
||||
success: false,
|
||||
message: `SMTP-Fehler: ${err.message || err}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
228
shop/lib/constants/branding.ts
Normal file
228
shop/lib/constants/branding.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
export interface BrandingSettings {
|
||||
companyName: string
|
||||
logoUrl?: string
|
||||
developerFooter?: string
|
||||
street: string
|
||||
zip: string
|
||||
city: string
|
||||
billingStreet: string
|
||||
billingZip: string
|
||||
billingCity: string
|
||||
sameBillingAddress: boolean
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
successColor?: string
|
||||
warningColor?: string
|
||||
destructiveColor?: string
|
||||
bgGlow1?: string
|
||||
bgGlow2?: string
|
||||
gradientFrom?: string
|
||||
gradientTo?: string
|
||||
cardBorder?: string
|
||||
textHighlight?: string
|
||||
buttonBg?: string
|
||||
ringColor?: string
|
||||
}
|
||||
|
||||
export interface ColorPreset {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
primary: string
|
||||
accent: string
|
||||
success: string
|
||||
warning: string
|
||||
destructive: string
|
||||
bgGlow1: string
|
||||
bgGlow2: string
|
||||
gradientFrom: string
|
||||
gradientTo: string
|
||||
cardBorder: string
|
||||
textHighlight: string
|
||||
buttonBg: string
|
||||
ringColor: string
|
||||
}
|
||||
|
||||
export const PRESET_COLOR_SCHEMES: ColorPreset[] = [
|
||||
{
|
||||
id: 'alabaster_racing_red',
|
||||
name: 'Alabaster & Racing Red',
|
||||
description: 'Sportlich, Dynamisch & High-End',
|
||||
primary: '#dc2626',
|
||||
accent: '#f8fafc',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#b91c1c',
|
||||
bgGlow1: '#ef4444',
|
||||
bgGlow2: '#7f1d1d',
|
||||
gradientFrom: '#dc2626',
|
||||
gradientTo: '#991b1b',
|
||||
cardBorder: 'rgba(220, 38, 38, 0.35)',
|
||||
textHighlight: '#fca5a5',
|
||||
buttonBg: '#dc2626',
|
||||
ringColor: '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'black_cherry_gold',
|
||||
name: 'Black Cherry & Gold',
|
||||
description: 'Luxuriös, Exklusiv & Tief',
|
||||
primary: '#881337',
|
||||
accent: '#f59e0b',
|
||||
success: '#10b981',
|
||||
warning: '#fbbf24',
|
||||
destructive: '#be123c',
|
||||
bgGlow1: '#9f1239',
|
||||
bgGlow2: '#d97706',
|
||||
gradientFrom: '#881337',
|
||||
gradientTo: '#d97706',
|
||||
cardBorder: 'rgba(245, 158, 11, 0.35)',
|
||||
textHighlight: '#fde68a',
|
||||
buttonBg: '#881337',
|
||||
ringColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
id: 'coffee_bean_cream',
|
||||
name: 'Coffee Bean & Cream',
|
||||
description: 'Warm, Organisch & Elegant',
|
||||
primary: '#78350f',
|
||||
accent: '#fde68a',
|
||||
success: '#059669',
|
||||
warning: '#d97706',
|
||||
destructive: '#991b1b',
|
||||
bgGlow1: '#92400e',
|
||||
bgGlow2: '#b45309',
|
||||
gradientFrom: '#78350f',
|
||||
gradientTo: '#b45309',
|
||||
cardBorder: 'rgba(253, 230, 138, 0.35)',
|
||||
textHighlight: '#fef3c7',
|
||||
buttonBg: '#78350f',
|
||||
ringColor: '#d97706',
|
||||
},
|
||||
{
|
||||
id: 'modern_blue',
|
||||
name: 'Modern Blue',
|
||||
description: 'Klassisch, Vertrauensvoll & Seriös',
|
||||
primary: '#2563eb',
|
||||
accent: '#38bdf8',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#ef4444',
|
||||
bgGlow1: '#3b82f6',
|
||||
bgGlow2: '#1d4ed8',
|
||||
gradientFrom: '#2563eb',
|
||||
gradientTo: '#1e40af',
|
||||
cardBorder: 'rgba(56, 189, 248, 0.35)',
|
||||
textHighlight: '#93c5fd',
|
||||
buttonBg: '#2563eb',
|
||||
ringColor: '#38bdf8',
|
||||
},
|
||||
{
|
||||
id: 'caspos_classic_blue',
|
||||
name: 'CASPOS Classic Blue',
|
||||
description: 'Das originale CASPOS-Blau – vertraut & clean',
|
||||
primary: '#3b82f6',
|
||||
accent: '#60a5fa',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#ef4444',
|
||||
bgGlow1: '#3b82f6',
|
||||
bgGlow2: '#1d4ed8',
|
||||
gradientFrom: '#3b82f6',
|
||||
gradientTo: '#2563eb',
|
||||
cardBorder: 'rgba(96, 165, 250, 0.35)',
|
||||
textHighlight: '#bfdbfe',
|
||||
buttonBg: '#3b82f6',
|
||||
ringColor: '#60a5fa',
|
||||
},
|
||||
{
|
||||
id: 'emerald_green',
|
||||
name: 'Emerald Green',
|
||||
description: 'Frisch, Nachhaltig & Vital',
|
||||
primary: '#059669',
|
||||
accent: '#34d399',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#e11d48',
|
||||
bgGlow1: '#10b981',
|
||||
bgGlow2: '#047857',
|
||||
gradientFrom: '#059669',
|
||||
gradientTo: '#065f46',
|
||||
cardBorder: 'rgba(52, 211, 153, 0.35)',
|
||||
textHighlight: '#a7f3d0',
|
||||
buttonBg: '#059669',
|
||||
ringColor: '#34d399',
|
||||
},
|
||||
{
|
||||
id: 'violet_glow',
|
||||
name: 'Violet Glow',
|
||||
description: 'Kreativ, Modern & Futuristisch',
|
||||
primary: '#7c3aed',
|
||||
accent: '#c084fc',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#be123c',
|
||||
bgGlow1: '#8b5cf6',
|
||||
bgGlow2: '#5b21b6',
|
||||
gradientFrom: '#7c3aed',
|
||||
gradientTo: '#4c1d95',
|
||||
cardBorder: 'rgba(192, 132, 252, 0.35)',
|
||||
textHighlight: '#ddd6fe',
|
||||
buttonBg: '#7c3aed',
|
||||
ringColor: '#c084fc',
|
||||
},
|
||||
{
|
||||
id: 'sunset_orange',
|
||||
name: 'Sunset Orange',
|
||||
description: 'Dynamisch, Aktiv & Energetisch',
|
||||
primary: '#ea580c',
|
||||
accent: '#fb923c',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#dc2626',
|
||||
bgGlow1: '#f97316',
|
||||
bgGlow2: '#9a3412',
|
||||
gradientFrom: '#ea580c',
|
||||
gradientTo: '#9a3412',
|
||||
cardBorder: 'rgba(251, 146, 60, 0.35)',
|
||||
textHighlight: '#ffedd5',
|
||||
buttonBg: '#ea580c',
|
||||
ringColor: '#fb923c',
|
||||
},
|
||||
{
|
||||
id: 'cyan_neon',
|
||||
name: 'Cyan Neon',
|
||||
description: 'Futuristisch, High-Tech & Klar',
|
||||
primary: '#0891b2',
|
||||
accent: '#22d3ee',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#f43f5e',
|
||||
bgGlow1: '#06b6d4',
|
||||
bgGlow2: '#155e75',
|
||||
gradientFrom: '#0891b2',
|
||||
gradientTo: '#155e75',
|
||||
cardBorder: 'rgba(34, 211, 238, 0.35)',
|
||||
textHighlight: '#cffafe',
|
||||
buttonBg: '#0891b2',
|
||||
ringColor: '#22d3ee',
|
||||
},
|
||||
{
|
||||
id: 'midnight_slate',
|
||||
name: 'Midnight Slate',
|
||||
description: 'Minimalistisch, Dunkel & Puristisch',
|
||||
primary: '#475569',
|
||||
accent: '#cbd5e1',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
destructive: '#ef4444',
|
||||
bgGlow1: '#64748b',
|
||||
bgGlow2: '#1e293b',
|
||||
gradientFrom: '#475569',
|
||||
gradientTo: '#0f172a',
|
||||
cardBorder: 'rgba(203, 213, 225, 0.35)',
|
||||
textHighlight: '#f1f5f9',
|
||||
buttonBg: '#475569',
|
||||
ringColor: '#cbd5e1',
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -119,8 +119,34 @@ export async function updateSession(request: NextRequest) {
|
||||
// the cookies!
|
||||
// 4. Finally:
|
||||
// return myNewResponse
|
||||
// If this is not done, you may be causing the browser and server to go out
|
||||
// of sync and terminate the user's session prematurely!
|
||||
// Inaktivitäts-Prüfung serverseitig (10 Minuten = 600.000 ms)
|
||||
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
if (user?.sub && !request.nextUrl.pathname.startsWith("/auth")) {
|
||||
const lastActivityCookie = request.cookies.get("webshop-last-activity");
|
||||
const now = Date.now();
|
||||
|
||||
if (lastActivityCookie?.value) {
|
||||
const lastActivityTime = parseInt(lastActivityCookie.value, 10);
|
||||
if (!isNaN(lastActivityTime) && now - lastActivityTime > INACTIVITY_TIMEOUT_MS) {
|
||||
// Länger als 10 Minuten inaktiv -> Auf dem Server sofort abmelden
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/auth/login";
|
||||
url.searchParams.set("message", "inactivity");
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.delete("webshop-auth-token");
|
||||
response.cookies.delete("webshop-last-activity");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// Aktuellen Zeitstempel in Cookie schreiben
|
||||
supabaseResponse.cookies.set("webshop-last-activity", now.toString(), {
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
httpOnly: false, // Für Client-Zugriff lesbar/schreibbar
|
||||
});
|
||||
}
|
||||
|
||||
return supabaseResponse;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ export type Product = {
|
||||
show_in_abo?: boolean
|
||||
requirements?: string[]
|
||||
exclusions?: string[]
|
||||
allow_update_discount?: boolean
|
||||
linked_fee_product_id?: string | null
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
@@ -134,6 +136,8 @@ export type OrderItem = {
|
||||
billing_interval: BillingInterval
|
||||
selected_modules: OrderModuleSnapshot[]
|
||||
item_total: number
|
||||
device_name?: string
|
||||
license_number?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +153,7 @@ export type OrderSnapshot = {
|
||||
total: number
|
||||
last_license_date?: string | null
|
||||
price_multiplier?: number | null
|
||||
end_customer?: any
|
||||
}
|
||||
|
||||
// ─── DB-Zeile (orders-Tabelle) ────────────────────────────────────────────────
|
||||
@@ -166,6 +171,40 @@ export type Order = {
|
||||
pdf_url: string | null
|
||||
status: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
created_at: string
|
||||
end_customer_data?: any
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export type OrderWithRegisterName = Order & {
|
||||
register_name: string
|
||||
}
|
||||
|
||||
export type EndCustomerWithOrders = EndCustomer & {
|
||||
orders: OrderWithRegisterName[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine einzelne Kasse, herausgelöst aus einem Order-Snapshot.
|
||||
* Ermöglicht die Darstellung einer Kasse = eine Karte (Flattening).
|
||||
*/
|
||||
export type FlattenedDevice = {
|
||||
orderId: string
|
||||
orderNumber: string
|
||||
orderStatus: Order['status']
|
||||
pdfUrl: string | null
|
||||
createdAt: string
|
||||
/** Eindeutiger Bezeichner der Kasse innerhalb der Order (= device_name) */
|
||||
deviceId: string
|
||||
/** Anzeigename der Kasse, bereinigt von Präfixen */
|
||||
deviceName: string
|
||||
/** Items (Produkte + Module) die zu dieser Kasse gehören */
|
||||
items: OrderItem[]
|
||||
/** Anteiliger Gesamtpreis dieser Kasse */
|
||||
totalPrice: number
|
||||
}
|
||||
|
||||
export type EndCustomerWithDevices = EndCustomer & {
|
||||
devices: FlattenedDevice[]
|
||||
}
|
||||
|
||||
// ─── Lizenz-Output (für Lizenzserver / ERP) ───────────────────────────────────
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,7 +19,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:54321 ws://127.0.0.1:54321 http://localhost:54321 ws://localhost:54321 https://*.supabase.co wss://*.supabase.co https://*.supabase.net wss://*.supabase.net; frame-ancestors 'self'; form-action 'self';",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user