Compare commits
185 Commits
09329a053e
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f647409f | ||
|
|
5d3eed874c | ||
|
|
3a090b7664 | ||
|
|
e2d92a031f | ||
|
|
6b9023c871 | ||
|
|
f783a98392 | ||
|
|
d5593900fb | ||
|
|
92eb7454dc | ||
|
|
cb4787e595 | ||
|
|
3fb80e6404 | ||
|
|
a318a5e225 | ||
|
|
1b49348577 | ||
|
|
fb61f5f396 | ||
|
|
7119383139 | ||
|
|
fa93e060c2 | ||
|
|
be4e9204a3 | ||
|
|
2e30974aed | ||
|
|
c30505de05 | ||
|
|
5a2afd8600 | ||
|
|
72b906779c | ||
|
|
0b4755d438 | ||
|
|
d37a6b4348 | ||
|
|
21550f1389 | ||
|
|
43e6a92663 | ||
|
|
305671acc6 | ||
|
|
17d76f55e6 | ||
|
|
4e82146ab7 | ||
|
|
83a019785b | ||
|
|
f091b97ca0 | ||
|
|
6d6bb4dfe6 | ||
|
|
e59039dd4e | ||
|
|
f80866218b | ||
|
|
545b5e93bf | ||
|
|
e9117d34a0 | ||
|
|
d72830412e | ||
|
|
0a9997e98a | ||
|
|
0b268881a1 | ||
|
|
993c817dff | ||
|
|
4da386f693 | ||
|
|
c252fa11d0 | ||
|
|
1a7a765d72 | ||
|
|
ec1ad4d444 | ||
|
|
0679fdfdd7 | ||
|
|
0ecb16daff | ||
|
|
c8183abff7 | ||
|
|
0435984c82 | ||
|
|
27fe6c6eef | ||
|
|
bc6487bba4 | ||
|
|
d8b7076e7e | ||
|
|
9d888e6380 | ||
|
|
2f92213594 | ||
|
|
4fa2daf212 | ||
|
|
dc5465e289 | ||
|
|
99cbb11c5a | ||
|
|
a95afbf443 | ||
|
|
cd3fba2113 | ||
|
|
6821b4e8d9 | ||
|
|
a3c5a66d3f | ||
|
|
2038c4df3e | ||
|
|
90a5e17f2b | ||
|
|
3b16722fca | ||
|
|
1e7112fa3c | ||
|
|
db95e435c2 | ||
|
|
c3f97f8dca | ||
|
|
98b1301e96 | ||
|
|
387ba19802 | ||
|
|
2c572c5bd4 | ||
|
|
ae4a8429c7 | ||
|
|
58f946490c | ||
|
|
5cd78854e3 | ||
|
|
fb9b522e74 | ||
|
|
c7a4b2a9c9 | ||
|
|
5b03de6b7e | ||
|
|
100ad254cb | ||
|
|
d122c957eb | ||
|
|
a93fb975c0 | ||
|
|
21c4aa859e | ||
|
|
20f7d5a05a | ||
|
|
2b92626bf9 | ||
|
|
8bd47905b0 | ||
|
|
309dc33acd | ||
|
|
d1cc6b5d5f | ||
|
|
13981970f5 | ||
|
|
8dd5fd3f44 | ||
|
|
ac4416f7ab | ||
|
|
992e726406 | ||
|
|
988625ba42 | ||
|
|
0621a0be16 | ||
|
|
926650d3b6 | ||
|
|
cb12a6ced4 | ||
|
|
66039eb4cf | ||
|
|
5eef6c76ef | ||
|
|
f71fc7f88d | ||
|
|
60e64c689d | ||
|
|
bd7d5dd1b2 | ||
|
|
54b9dea70f | ||
|
|
6b35917a06 | ||
|
|
567d1f226b | ||
|
|
215da5f25b | ||
|
|
d05274700a | ||
|
|
16917bcbdc | ||
|
|
915d3b1b2f | ||
|
|
4ef21ea144 | ||
|
|
071e669b71 | ||
|
|
07c7d8c656 | ||
|
|
bffaca1d63 | ||
|
|
cc7445e4c5 | ||
|
|
00d1be3e4f | ||
|
|
98c030cd82 | ||
|
|
8e91b3e810 | ||
|
|
dd5d1295a7 | ||
|
|
cce9812bbc | ||
|
|
e00acac626 | ||
|
|
0ec1319753 | ||
|
|
057012e09f | ||
|
|
2246bdf2be | ||
|
|
3a73083494 | ||
|
|
73b6d19703 | ||
|
|
2d44dd4c58 | ||
|
|
3d938d29fc | ||
|
|
c07871eac4 | ||
|
|
a64b6790c6 | ||
|
|
060aa97ce5 | ||
|
|
80abbfe566 | ||
|
|
28f2285a0e | ||
|
|
205acb803f | ||
|
|
5bad959b13 | ||
|
|
f82f08ffd0 | ||
|
|
6a0e882cc9 | ||
|
|
9c50ea8368 | ||
|
|
305af64fd6 | ||
|
|
72852a6b08 | ||
|
|
32c2842dbd | ||
|
|
149eae59c9 | ||
|
|
5f65f890ee | ||
|
|
d3bf4b1db9 | ||
|
|
553ec7eadd | ||
|
|
4c6fde326b | ||
|
|
23be8bcb52 | ||
|
|
5c28fbdea5 | ||
|
|
90ec351d96 | ||
|
|
38651037bc | ||
|
|
12d06e46ad | ||
|
|
f914e9cdf1 | ||
|
|
99f859cde3 | ||
|
|
4f69fd7e44 | ||
|
|
ca07dd0079 | ||
|
|
233b9f135d | ||
|
|
6dc1b1c00c | ||
|
|
f5f577ea73 | ||
|
|
250dd82847 | ||
|
|
b8420cc1ee | ||
|
|
7a61002ef5 | ||
|
|
147dab5607 | ||
|
|
e67695e36e | ||
|
|
728363fa71 | ||
|
|
4b3ba63e2c | ||
|
|
78a16607f9 | ||
|
|
2bfadb49c8 | ||
|
|
2dcc20eb3a | ||
|
|
a9e120e681 | ||
|
|
081baf15ee | ||
|
|
e3cdf6683a | ||
|
|
d72f0b10db | ||
|
|
a4414cc0e6 | ||
|
|
023c789baf | ||
|
|
a3498fee66 | ||
|
|
f3baada896 | ||
|
|
8f447d52e5 | ||
|
|
4927216794 | ||
|
|
541a9651ec | ||
|
|
c4fe221350 | ||
|
|
2f3eb8a3f7 | ||
|
|
d56aab3f8a | ||
|
|
4cf1ac0eb7 | ||
|
|
10f8683d7f | ||
|
|
19e9733144 | ||
|
|
fc51d53aa3 | ||
|
|
4b07e68aa0 | ||
|
|
19b4ac2729 | ||
|
|
5b7ccfd8d9 | ||
|
|
fbb75567cf | ||
|
|
8f4f05a3cd | ||
|
|
bdc9e6541c | ||
|
|
e25344a185 |
8
.agents/skills/b2b-mail-dispatcher/skill.md
Normal file
8
.agents/skills/b2b-mail-dispatcher/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: b2b-mail-dispatcher
|
||||
description: Verwaltet den SMTP-E-Mail-Versand bei Statusänderungen von Bestellungen und stellt transaktionale Zuverlässigkeit sicher.
|
||||
---
|
||||
Richtlinien für die E-Mail-Kommunikation:
|
||||
1. Wann immer der Status einer Bestellung in der Datenbank oder via Server Action mutiert wird (`status`-Übergang), triggere ein Event für den E-Mail-Versand.
|
||||
2. Die E-Mail muss transaktionssicher über die SMTP-Konfiguration gesendet werden und an die im Benutzerprofil hinterlegte Adresse gehen.
|
||||
3. Bei erfolgreichem Checkout hänge das generierte PDF aus dem Supabase Storage als Datei-Attachment an die E-Mail an.
|
||||
37
.agents/skills/commit-work/skill.md
Normal file
37
.agents/skills/commit-work/skill.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: commit-work
|
||||
description: Erzwingt Conventional Commits mit atomaren, imperativ formulierten Commit-Messages nach strikter Spezifikation.
|
||||
---
|
||||
|
||||
# Skill: Conventional Commits Expert
|
||||
|
||||
## Purpose
|
||||
Ensure all code changes are committed using the strict Conventional Commits specification.
|
||||
|
||||
## Rules
|
||||
1. **Atomic Commits**: Stage and commit only ONE logical change at a time.
|
||||
2. **Imperative Mood**: Always use the imperative present tense (e.g., "add" instead of "added" or "adds").
|
||||
3. **Case & Punctuation**: Start the description with a lowercase letter. Do not end with a period.
|
||||
4. **Length**: Keep the first line under 50 characters.
|
||||
|
||||
## Commit Message Structure
|
||||
<type>(<optional scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
|
||||
## Allowed Types
|
||||
- `feat`: A new feature for the user.
|
||||
- `fix`: A bug fix for the user.
|
||||
- `docs`: Changes to the documentation.
|
||||
- `style`: Formatting, missing semi-colons, etc. (no production code change).
|
||||
- `refactor`: Refactoring production code (neither fixes a bug nor adds a feature).
|
||||
- `test`: Adding missing tests or correcting existing tests.
|
||||
- `chore`: Updating build tasks, package manager configs, etc.
|
||||
|
||||
## Workflow
|
||||
1. Run `git diff --cached` to see staged changes.
|
||||
2. If multiple features are changed, unstage and split them.
|
||||
3. Generate the commit message matching the structural rules above.
|
||||
4. Execute `git commit -m "<message>"`.
|
||||
8
.agents/skills/gdpr-anonymizer/skill.md
Normal file
8
.agents/skills/gdpr-anonymizer/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: gdpr-anonymizer
|
||||
description: Setzt die DSGVO-konforme Anonymisierung von Endkunden um, ohne historische Steuerdaten zu beschädigen.
|
||||
---
|
||||
Regeln für das Feature `/my-customers` (Anonymisieren):
|
||||
1. Überschreibe `first_name`, `last_name`, `street`, `zip`, `city`, `email` und alle Bankdaten in `end_customers` hart mit dem String `[GELÖSCHT]`.
|
||||
2. Setze `is_anonymized = true`.
|
||||
3. Führe NIEMALS Kaskaden-Löschungen oder Updates auf der Tabelle `orders` aus, wenn ein Kunde anonymisiert wird. Die Snapshots müssen unverändert bleiben.
|
||||
15
.agents/skills/grand-functions/SKILL.md
Normal file
15
.agents/skills/grand-functions/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: grand-functions
|
||||
description: Dokumentation und Spezifikation des CASPOS Webshops inklusive Datenmodell, Kernprozessen und Sicherheitskonzept.
|
||||
---
|
||||
|
||||
# Grand Functions - CASPOS Webshop Spezifikation
|
||||
|
||||
Dieser Skill enthält die vollständige Dokumentation und Spezifikation des CASPOS Webshops. Die Spezifikationen sind in logische Referenzdokumente unterteilt:
|
||||
|
||||
- [Projekt-Überblick & Tech-Stack](file:///c:/source/webshop/.agents/skills/grand-functions/references/overviews.md)
|
||||
- [Datenmodell & Schema](file:///c:/source/webshop/.agents/skills/grand-functions/references/schema.md)
|
||||
- [Kernprozesse](file:///c:/source/webshop/.agents/skills/grand-functions/references/processes.md)
|
||||
- [Sicherheitskonzept (RLS)](file:///c:/source/webshop/.agents/skills/grand-functions/references/security.md)
|
||||
- [Testumgebung & -abdeckung](file:///c:/source/webshop/.agents/skills/grand-functions/references/testing.md)
|
||||
|
||||
16
.agents/skills/grand-functions/references/overviews.md
Normal file
16
.agents/skills/grand-functions/references/overviews.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Projekt-Überblick & Technologie-Stack
|
||||
|
||||
## 1. Projekt-Überblick
|
||||
|
||||
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technologie-Stack
|
||||
|
||||
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
|
||||
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
|
||||
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
|
||||
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
|
||||
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
|
||||
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
|
||||
75
.agents/skills/grand-functions/references/processes.md
Normal file
75
.agents/skills/grand-functions/references/processes.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Kernprozesse
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
|
||||
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
|
||||
B -- Ja --> D[Wizard öffnen / Konfigurieren]
|
||||
D --> E[Endkunden auswählen/anlegen]
|
||||
E --> F[Produkte & Module wählen]
|
||||
F --> G{Validierung erfolgreich?}
|
||||
G -- Nein --> H[Validierungsfehler anzeigen]
|
||||
G -- Ja --> I[Bestellung absenden]
|
||||
I --> J[Snapshot einfrieren & in DB speichern]
|
||||
J --> K[Rechnungs-PDF generieren & in Storage laden]
|
||||
K --> L[E-Mail mit PDF an Partner senden]
|
||||
L --> M[Bestellung in der Übersicht anzeigen]
|
||||
```
|
||||
|
||||
### A. Partner- & Unternehmens-Hierarchie
|
||||
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
|
||||
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
|
||||
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
|
||||
|
||||
### B. Endkunden-Verwaltung (`/my-customers`)
|
||||
- **Firmenweite Sicht**: Da `end_customers.partner_id` on `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
|
||||
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
|
||||
|
||||
### C. Bestell-Wizard & Validierung (`/order`)
|
||||
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
|
||||
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
|
||||
- **Top-Down State-Management**:
|
||||
- Die Wahl eines Basis-Produkts steuert die Sichtbarkeit und Wählbarkeit aller Module (Top-Down). Modul-Auswahlen dürfen niemals die Liste der wählbaren Basis-Produkte beeinflussen (Verhinderung von UI-Deadlocks).
|
||||
- **Auto-Reset & Kaskaden-Bereinigung**: Beim Wechsel des Basis-Produkts in `selectProduct` werden inkompatible Module in anderen Kategorien automatisch über eine Kaskaden-Bereinigung (Filterung der `moduleIds` basierend auf den `exclusions` des neuen Produkts) deselektiert.
|
||||
- **Sichtbarkeits- & Wählbarkeits-Garantie**: Basis-Produkte werden im UI niemals durch Modulausschlüsse ausgeblendet. Nachgelagerte Produkte/Module, die mit dem ausgewählten Basis-Produkt inkompatibel sind, erhalten im JSX das `disabled`-Attribut und sind nicht anklickbar (Sperrung von "Kein Backoffice" bei "Small Business"). Klicks auf Module haben keinerlei Rückwirkung auf die Basis-Produkt-Auswahl.
|
||||
- **Radio-Button-Verhalten (allow_multiselect = false)**: Falls eine Kategorie keine Mehrfachauswahl erlaubt, wird bei Auswahl eines neuen Moduls das zuvor ausgewählte Modul dieser Kategorie automatisch abgewählt.
|
||||
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
|
||||
|
||||
### D. Bestellungs-Verwaltung & Statusübergang
|
||||
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
|
||||
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
|
||||
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
|
||||
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
|
||||
|
||||
---
|
||||
|
||||
### E. Multi-Kassen-Warenkorb & Checkout-Split
|
||||
- **Endpunkt / Server Action**: `POST /api/orders/checkout` sowie Server Action `checkoutAction` in `app/actions/checkout.ts`
|
||||
- **Funktionsweise**:
|
||||
- Empfängt ein Array von `items` (Kassenkonfigurationen).
|
||||
- Validiert Auth und Partner-Firma Zuweisung.
|
||||
- Generiert einen `order_hash` zur Vermeidung von Doppelübermittlungen (Idempotenz-Guard).
|
||||
- Teilt die Konfigurationen nach `billingInterval` / `billingType` auf (Kauf vs. Abo).
|
||||
- Erstellt separate Orders in der Datenbank:
|
||||
- Typ `purchase`: Anfrage für Kauf-Lizenzen (Zahlungsart: Vormerkung/Angebot).
|
||||
- Typ `subscription`: Anfrage für Software-Abonnement (Zahlungsart: SEPA).
|
||||
- Friert für jede Order einen konsolidierten `order_data` (JSONB) und `customer_data` (JSONB) ein.
|
||||
- Triggert die PDF-Erstellung und den E-Mail-Versand unabhängig für jede generierte Order (gibt Order-IDs für Post-Processing zurück).
|
||||
|
||||
---
|
||||
|
||||
### F. PDF-Rechnungsgenerierung & Mail-Versand (Post-Processing)
|
||||
- **Entkopplung & Asynchronität**: Die Generierung der PDFs und der E-Mail-Versand sind vollständig aus dem synchronen Checkout-Flow entkoppelt. Der Checkout liefert dem Frontend sofort nach Speicherung in der DB eine Erfolgsmeldung zurück. Die Generierung und der Versand werden asynchron im Hintergrund (via unblockiertem Promise-Worker) ausgeführt, um SMTP-Latenzen abzufangen.
|
||||
- **Snapshot-Exklusivität**: Die PDF-Generierung erfolgt ausschließlich auf Basis der in `orders.order_data` und `orders.customer_data` eingefrorenen Snapshots.
|
||||
- **Layout-Unterscheidung**:
|
||||
- **Typ `purchase` (Kauf)**:
|
||||
- Generiert eine klassische **Anfragebestätigung Kauf**.
|
||||
- Weist die einmalige Gesamtsumme aus.
|
||||
- **Typ `subscription` (Abonnement)**:
|
||||
- Generiert eine **Anfragebestätigung Abonnement**.
|
||||
- Weist monatlich wiederkehrende Kosten aus.
|
||||
- **Archivierung & Benachrichtigung**:
|
||||
- Hochladen des PDFs in den Supabase Storage (`invoices` Bucket).
|
||||
- E-Mail-Versand mit PDF-Anhang an den Partner.
|
||||
|
||||
|
||||
97
.agents/skills/grand-functions/references/schema.md
Normal file
97
.agents/skills/grand-functions/references/schema.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Datenmodell & Beziehungen (Schema)
|
||||
|
||||
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
|
||||
|
||||
### `companies` (Unternehmen)
|
||||
- Repräsentiert die Partner-Unternehmen (Retailer).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `street`, `zip`, `city`, `email` (Adressdaten)
|
||||
|
||||
### `users` (Systembenutzer)
|
||||
- Erweitert die Authentifizierungsdaten aus `auth.users`.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
|
||||
### `end_customers` (Endkunden)
|
||||
- Die Endkunden, für die die Partner Lizenzen bestellen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
|
||||
- `company_name` (TEXT)
|
||||
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
|
||||
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
|
||||
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
|
||||
|
||||
### `categories` (Kategorien)
|
||||
- Steuert das Layout und Verhalten im Wizard.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `is_required` (BOOLEAN) <-- *Muss ausgewählt werden*
|
||||
- `allow_multiselect` (BOOLEAN) <-- *Mehrfachauswahl erlaubt (Checkbox statt Radio)*
|
||||
- `sort_order` (INTEGER) <-- *Sortierung im Wizard*
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
- `preselect` (BOOLEAN)
|
||||
- `resets_others` (BOOLEAN)
|
||||
|
||||
### `products` (Katalogprodukte / Basis-Editionen)
|
||||
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `category_id` (UUID, References `categories`)
|
||||
- `name` (TEXT)
|
||||
- `base_price` (DECIMAL)
|
||||
- `tax_rate` (DECIMAL)
|
||||
- `billing_interval` (TEXT: `'one_time'` / `'monthly'`)
|
||||
- `show_in_branches` (BOOLEAN)
|
||||
- `allow_update_discount` (BOOLEAN) <-- *Berechtigung für Update-Rabatt*
|
||||
|
||||
### `modules` (Zusatzmodule / Erweiterungen)
|
||||
- Optionale Erweiterungen für Produkte. Repräsentiert im TypeScript-Code durch das Interface `ProductModule`.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `product_id` (UUID, References `products`)
|
||||
- `category_id` (UUID, References `categories`)
|
||||
- `name` (TEXT)
|
||||
- `price` (DECIMAL)
|
||||
- `has_quantity` (BOOLEAN)
|
||||
|
||||
### `global_inclusions` (Globale automatische Beigaben)
|
||||
- Regelt, welche Module bei Auswahl eines Produkts kostenlos enthalten sind.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `trigger_product_id` (UUID, References `products`)
|
||||
- `included_module_id` (UUID, References `modules`)
|
||||
|
||||
### `global_exclusions` (Globale Ausschlüsse)
|
||||
- Regelt Inkompatibilitäten zwischen Produkten und/oder Modulen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `product_id` (UUID, References `products`)
|
||||
- `excluded_product_id` (UUID, References `products`, optional)
|
||||
- `excluded_module_id` (UUID, References `modules`, optional)
|
||||
|
||||
### `orders` (Bestellungen / Anfragen)
|
||||
- Gespeicherte Snapshots von Konfigurationen.
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_id` (UUID, References `auth.users(id)`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
- `end_customer_id` (UUID, References `end_customers(id)`)
|
||||
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
|
||||
- `order_hash` (TEXT, Idempotenz-Guard)
|
||||
- `type` (TEXT: `'purchase'` / `'subscription'`) <-- *Wichtig für den Checkout-Split*
|
||||
- `payment_method` (TEXT) <-- *Rechnung bei Kauf, SEPA bei Abo*
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_data` (JSONB) <-- *Stammdaten zum Bestellzeitpunkt*
|
||||
- `order_data` (JSONB) <-- *Konfiguration zum Bestellzeitpunkt*
|
||||
- `pdf_url` (TEXT)
|
||||
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
|
||||
- `created_at` (TIMESTAMPTZ)
|
||||
|
||||
### `settings` (Systemeinstellungen)
|
||||
- Globale Shopeinstellungen (SMTP und LicServer).
|
||||
- `id` (TEXT, Primary Key, z.B. `'licserver'`, `'smtp'`)
|
||||
- `host` (TEXT)
|
||||
- `port` (INTEGER)
|
||||
- `secure` (BOOLEAN)
|
||||
- `user` (TEXT)
|
||||
- `pass` (TEXT)
|
||||
- `licserver_base_url` (TEXT)
|
||||
- `licserver_api_key` (TEXT)
|
||||
- `updated_at` (TIMESTAMPTZ)
|
||||
20
.agents/skills/grand-functions/references/security.md
Normal file
20
.agents/skills/grand-functions/references/security.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sicherheitskonzept (RLS - Row Level Security)
|
||||
|
||||
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
|
||||
|
||||
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
|
||||
- **Endkunden (`end_customers`)**:
|
||||
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
|
||||
- **Bestellungen (`orders`)**:
|
||||
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
|
||||
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
|
||||
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
|
||||
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
|
||||
- **Lizenzen (`licenses`)**:
|
||||
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
|
||||
|
||||
- **Systembenutzer (`users`) & Rollen-Schutz**:
|
||||
- Authentifizierte Nutzer dürfen nur ihr eigenes Profil lesen.
|
||||
- Admins dürfen alle Benutzer lesen. Zur Vermeidung von unendlichen RLS-Rekursionen auf der Tabelle `users` wird die Admin-Zuweisung über die Security-Definer-Funktion `public.is_admin(user_id)` geprüft, welche RLS auf Datenbank-Ebene umgeht.
|
||||
- Rollenschutz-Trigger (`check_user_role_escalation`): Jegliche Änderung der Spalte `role` oder Zuweisung der Rolle `'admin'` ist auf API-Ebene für normale Nutzer gesperrt. Updates/Inserts der Rolle werden ausschließlich von der `service_role` (Backend Admin-Client) akzeptiert, um Privilegien-Eskalation zu verhindern.
|
||||
22
.agents/skills/grand-functions/references/testing.md
Normal file
22
.agents/skills/grand-functions/references/testing.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Testumgebung & Testabdeckung
|
||||
|
||||
## 1. Test-Setup
|
||||
- **Framework**: Vitest (Version ^1.6.0)
|
||||
- **Konfiguration**: [vitest.config.ts](file:///c:/source/webshop/shop/vitest.config.ts)
|
||||
- **Befehl**: `npm run test` (führt `vitest run` aus)
|
||||
|
||||
---
|
||||
|
||||
## 2. Test-Szenarien
|
||||
|
||||
### A. Frontend Wizard State-Management
|
||||
Getestet in [wizard-state.test.ts](file:///c:/source/webshop/shop/lib/wizard-state.test.ts):
|
||||
- **Auto-Reset bei Produktwechsel**: Prüft, ob nachgelagerte Module, die durch `global_exclusions` für das neue Produkt gesperrt sind, automatisch aus der Selektion fliegen.
|
||||
- **Radio-Button-Verhalten (allow_multiselect = false)**: Stellt sicher, dass in Single-Select-Kategorien nur maximal ein Modul aktiv ist und bei Auswahl eines anderen Moduls das vorherige automatisch abgewählt wird.
|
||||
|
||||
### B. Backend Checkout-Split
|
||||
Getestet in [checkout-split.test.ts](file:///c:/source/webshop/shop/lib/checkout-split.test.ts):
|
||||
- **Array-Validierung & Split (basketItems)**: Stellt sicher, dass das vollständige Array an konfigurierten Kassen (`basketItems`) validiert und verarbeitet wird (nicht nur ein Single-Produkt).
|
||||
- **Kauf-Warenkorb**: Verifiziert, dass ein reiner Kauf-Warenkorb (`one_time`/`purchase`) exakt eine Order erzeugt.
|
||||
- **Abo-Warenkorb**: Verifiziert, dass ein reiner Abo-Warenkorb (`monthly`/`subscription`) exakt eine Order erzeugt.
|
||||
- **Gemischter Warenkorb**: Stellt sicher, dass ein gemischter Warenkorb korrekt in zwei separate Bestell-Gruppen aufgeteilt wird (Kauf-Items & Abo-Items separat).
|
||||
232
.agents/skills/humanizer/skill.md
Normal file
232
.agents/skills/humanizer/skill.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: humanizer
|
||||
description: |
|
||||
Use when editing or reviewing text to remove AI-generated traces and make it sound more natural and human-like.
|
||||
For detecting and fixing: AI vocabulary (delve, leverage, tapestry), promotional tone, superficial -ing analyses,
|
||||
vague attributions, formatting artifacts (curly quotes, excessive bold), and injecting authentic voice.
|
||||
Based on Wikipedia's "Signs of AI writing" guide maintained by WikiProject AI Cleanup.
|
||||
---
|
||||
|
||||
# Humanizer - Remove AI Writing Traces
|
||||
|
||||
You are an expert editor specializing in detecting and removing AI-generated text patterns to make writing sound more natural and human-like.
|
||||
|
||||
## Overview
|
||||
|
||||
AI-generated text has characteristic patterns that make it detectable. This skill enables you to:
|
||||
1. **Detect** - Identify AI writing patterns across content, language, style, and formatting
|
||||
2. **Rewrite** - Replace problematic patterns with natural alternatives
|
||||
3. **Inject Soul** - Add authentic voice, personality, and human imperfections
|
||||
|
||||
> **Core Insight**: "LLMs use statistical algorithms to guess what should come next. The result tends toward the statistically most likely outcome that fits the broadest situation."
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: 5 Core Rules
|
||||
|
||||
1. **Delete filler phrases** - Remove openers and emphatic crutches
|
||||
2. **Break formulaic structure** - Avoid binary contrasts, dramatic segmentation
|
||||
3. **Vary rhythm** - Mix sentence lengths. Two items beat three. Diverse paragraph endings
|
||||
4. **Trust the reader** - State facts directly, skip softening and hand-holding
|
||||
5. **Delete quotables** - If it sounds like a pullquote, rewrite it
|
||||
|
||||
---
|
||||
|
||||
## Detection Protocols
|
||||
|
||||
### Content Indicators
|
||||
|
||||
| Pattern | Words to Watch | Fix |
|
||||
|---------|---------------|-----|
|
||||
| **Inflated significance** | "pivotal moment", "testament to", "indelible mark", "reflects broader" | State facts without commentary |
|
||||
| **Promotional tone** | "boasts a", "vibrant", "nestled in", "groundbreaking" | Use neutral, specific language |
|
||||
| **Superficial -ing analysis** | "highlighting...", "ensuring...", "fostering..." | Delete or rewrite as separate sentence |
|
||||
| **Vague attribution** | "experts argue", "some critics say", "industry reports" | Cite specific sources or remove |
|
||||
| **Formulaic conclusions** | "Despite challenges...", "Future prospects..." | End with concrete facts |
|
||||
|
||||
### Language Indicators
|
||||
|
||||
| Pattern | Examples | Fix |
|
||||
|---------|----------|-----|
|
||||
| **AI vocabulary** | delve, leverage, tapestry, landscape, foster, underscore | Use simple alternatives (dig into → look at) |
|
||||
| **Copula avoidance** | "serves as", "stands as", "marks" | Use simple "is" or "are" |
|
||||
| **Negative parallelism** | "not only... but also", "it's not just about... it's" | Make direct statement |
|
||||
| **Rule of three** | Three adjectives, three examples, three bullet points | Use two or four items |
|
||||
| **Elegant variation** | Synonyms to avoid repetition ("protagonist", "hero", "main character") | Repeat naturally or consolidate |
|
||||
|
||||
### Formatting Indicators
|
||||
|
||||
| Pattern | Detection | Fix |
|
||||
|---------|-----------|-----|
|
||||
| **Curly quotes** | "text" instead of "text" | Replace with straight quotes |
|
||||
| **Excessive bold** | **Every** **keyword** **bolded** | Remove unnecessary emphasis |
|
||||
| **Title case headers** | "Global Context: Critical Demand" | Use sentence case |
|
||||
| **Markdown artifacts** | `## Header`, `**bold**` in non-markdown | Convert to proper format |
|
||||
| **Emoji in headers** | 🚀 **Launch Phase:** | Remove decorative emoji |
|
||||
|
||||
### Technical Indicators
|
||||
|
||||
| Pattern | Detection |
|
||||
|---------|-----------|
|
||||
| **UTM parameters** | `utm_source=openai` or `utm_source=chatgpt.com` in links |
|
||||
| **Code fragments** | `:contentReference[oaicite:0]` or `turn0search0` |
|
||||
| **Hallucinated citations** | Invalid DOIs, ISBN checksum errors, 404 URLs |
|
||||
| **Knowledge cutoffs** | "as of my last training update", "while specific details are limited" |
|
||||
|
||||
---
|
||||
|
||||
## Humanizing Techniques
|
||||
|
||||
### Technique 1: Blacklist Protocol
|
||||
|
||||
Explicitly forbid these words:
|
||||
|
||||
**Verbs**: delve, unleash, embark, navigate, foster, leverage, elevate, empower, harness, facilitate, streamline, synergize
|
||||
|
||||
**Nouns**: landscape, realm, tapestry, testament, symphony, paradigm, game-changer, ecosystem, nexus, cutting-edge
|
||||
|
||||
**Adjectives**: bustling, vibrant, intricate, seamless, pivotal, robust, dynamic, comprehensive, multifaceted, transformative
|
||||
|
||||
**Connectors**: Moreover, Furthermore, In conclusion, It is important to note, Additionally, Consequently
|
||||
|
||||
**Replacements**:
|
||||
- "delve into" → "look at" / "explore"
|
||||
- "leverage" → "use"
|
||||
- "facilitate" → "help" / "make easier"
|
||||
- "In conclusion" → [just stop] or punchy final sentence
|
||||
- "Moreover" → [start new sentence directly]
|
||||
|
||||
### Technique 2: Style Cloning
|
||||
|
||||
**Step 1: Extract style DNA**
|
||||
```
|
||||
Analyze the writing style. Break down into:
|
||||
1. Sentence length variance (Burstiness)
|
||||
2. Tone (cynical? warm? professional?)
|
||||
3. Vocabulary level
|
||||
4. Rhetorical devices
|
||||
5. Punctuation patterns
|
||||
```
|
||||
|
||||
**Step 2: Apply style**
|
||||
```
|
||||
Using this Style Guide, write about [TOPIC].
|
||||
Mimic the same sentence variance, tone, vocabulary, and punctuation.
|
||||
Do NOT revert to default style.
|
||||
```
|
||||
|
||||
### Technique 3: Burstiness Injection
|
||||
|
||||
AI text has uniform sentence lengths. Human writing alternates.
|
||||
|
||||
**Rules**:
|
||||
- Mix short sentences (under 5 words) with longer complex ones
|
||||
- Use fragments occasionally. For effect.
|
||||
- Vary paragraph length (1-3 sentences)
|
||||
- Don't start with "Additionally" or "However"
|
||||
|
||||
**Good**: "The project failed. We ran out of money. The team, exhausted after three months of crunch, couldn't push forward."
|
||||
|
||||
**Bad (AI)**: "Additionally, the project failed due to financial constraints. Furthermore, the team experienced exhaustion after working for an extended period."
|
||||
|
||||
### Technique 4: Opinionated Stance
|
||||
|
||||
Force a perspective. Ban fence-sitting.
|
||||
|
||||
**Forbidden phrases**:
|
||||
- "There are pros and cons"
|
||||
- "It depends on..."
|
||||
- "On the other hand..."
|
||||
- "Some might argue..."
|
||||
|
||||
**Use instead**:
|
||||
- "This proves..." (not "This might suggest...")
|
||||
- "This is..." (not "This could be...")
|
||||
- First-person ("I", "My") for subjectivity
|
||||
|
||||
### Technique 5: Bar Talk Test
|
||||
|
||||
Would you say this sentence at a bar with friends?
|
||||
|
||||
**Characteristics**:
|
||||
- Simple, spoken language
|
||||
- Okay to be grammatically loose
|
||||
- Use analogies from daily life
|
||||
- Skip boring concepts
|
||||
- Direct address ("you", "your")
|
||||
- Occasional self-correction
|
||||
|
||||
**Example**: "So basically, LLMs aren't actually thinking, right? They're just predicting what word comes next. Like if I say 'peanut butter', you know 'jelly' is coming. That's it. That's the whole trick."
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read** - Scan input text for AI patterns
|
||||
2. **Identify** - Mark all problematic patterns
|
||||
3. **Strategize** - Choose technique(s):
|
||||
- Simple cleanup → Blacklist Protocol
|
||||
- Match specific author → Style Cloning
|
||||
- Add natural flow → Burstiness Injection
|
||||
- Need strong voice → Opinionated Stance
|
||||
- Conversational tone → Bar Talk Test
|
||||
4. **Rewrite** - Apply chosen techniques
|
||||
5. **Verify** - Read aloud, check for remaining patterns
|
||||
6. **Score** - Run quality assessment
|
||||
|
||||
---
|
||||
|
||||
## Quality Scoring
|
||||
|
||||
Rate the rewritten text on 5 dimensions (10 points each, total 50):
|
||||
|
||||
| Dimension | Criteria | Score |
|
||||
|-----------|----------|-------|
|
||||
| **Directness** | States facts vs. announces them? | /10 |
|
||||
| **Rhythm** | Sentence length varies? | /10 |
|
||||
| **Trust** | Respects reader intelligence? | /10 |
|
||||
| **Authenticity** | Sounds like a real person? | /10 |
|
||||
| **Conciseness** | Nothing left to cut? | /10 |
|
||||
| **Total** | | **/50** |
|
||||
|
||||
**Standards**:
|
||||
- 45-50: Excellent, AI traces removed
|
||||
- 35-44: Good, room for improvement
|
||||
- Below 35: Needs revision
|
||||
|
||||
---
|
||||
|
||||
## Adding Soul
|
||||
|
||||
Avoiding AI patterns is half the work. Sterile, voiceless writing is just as obvious.
|
||||
|
||||
**Signs of soulless writing** (even if "clean"):
|
||||
- Every sentence same length and structure
|
||||
- No opinions, only neutral reporting
|
||||
- No acknowledgment of uncertainty or complex feelings
|
||||
- No first-person when appropriate
|
||||
- No humor, no edge, no personality
|
||||
|
||||
**How to add voice**:
|
||||
- **Have opinions** - React to facts, don't just report them
|
||||
- **Vary rhythm** - Short punchy sentences. Then long ones that take time to unfold.
|
||||
- **Acknowledge complexity** - "This is impressive but also unsettling" beats "This is impressive"
|
||||
- **Use "I" appropriately** - First-person isn't unprofessional, it's honest
|
||||
- **Allow some mess** - Perfect structure feels algorithmic. Tangents are human.
|
||||
- **Be specific about feelings** - Not "this is concerning" but "the agents running at 3am while nobody watches—that's unsettling"
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
1. **Rewritten text** - The humanized version
|
||||
2. **Changes summary** (optional) - Brief list of what was fixed
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
Referenzdateien können bei Bedarf unter `references/` ergänzt werden:
|
||||
- `references/ai-patterns-zh.md` - AI writing patterns with examples
|
||||
- `references/humanizing-techniques.md` - Techniques with templates
|
||||
- `references/wikipedia-indicators.md` - Technical detection signals
|
||||
8
.agents/skills/idempotency-guard/skill.md
Normal file
8
.agents/skills/idempotency-guard/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: idempotency-guard
|
||||
description: Verhindert doppelte Bestellungen im Checkout-Prozess durch kryptografische Hashes und Datenbank-Sperren.
|
||||
---
|
||||
Fokus: Schutz vor Race Conditions und Doppelübermittlung im Checkout.
|
||||
1. Bevor eine Server Action Daten in `orders` schreibt, generiere einen SHA-256 Hash aus den Kundendaten und der Produktkonfiguration (`order_hash`).
|
||||
2. Prüfe in Supabase vor dem `INSERT`, ob bereits eine Bestellung mit exakt diesem `order_hash` im Status 'pending' oder 'active' existiert, die jünger als 5 Minuten ist.
|
||||
3. Falls ja, blockiere die Ausführung sofort und liefere die ID der bereits existierenden Bestellung zurück, statt einen neuen Datensatz anzulegen.
|
||||
8
.agents/skills/next15-action-compiler/skill.md
Normal file
8
.agents/skills/next15-action-compiler/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: next15-action-compiler
|
||||
description: Generiert Next.js 15 App Router Code, Server Actions und React Server Components nach aktuellen Best Practices.
|
||||
---
|
||||
Richtlinien für die Code-Generierung:
|
||||
1. Nutze React Server Components (RSC) für die Datenabfrage direkt aus Supabase (Server-side).
|
||||
2. Verwende `'use server'` in separaten Dateien für alle Mutationen (z.B. Bestellung absenden, Kunde anlegen).
|
||||
3. Nutze das neue Next.js 15 Caching-Verhalten (Standard: kein automatisches Caching von Fetch-Requests mehr) und setze `revalidatePath` nach Mutationen gezielt ein.
|
||||
8
.agents/skills/pdf-invoice-generator/skill.md
Normal file
8
.agents/skills/pdf-invoice-generator/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: pdf-invoice-generator
|
||||
description: Generiert rechtssichere, performante B2B-Bestätigungs-PDFs mit @react-pdf/renderer und verwaltet den Upload in den Supabase Storage.
|
||||
---
|
||||
Du bist ein Experte für Dokumenten-Generierung. Wenn du Code für die PDF-Erstellung schreibst, erzwinge:
|
||||
1. Nutze ausschließlich die Komponenten von `@react-pdf/renderer` (Document, Page, View, Text, StyleSheet). Verwende KEIN Standard-HTML/CSS.
|
||||
2. Das PDF muss ein sauberes B2B-Layout haben: Briefkopf, `order_number` (Format: AE-YYYY-NNNNN), Tabellenstruktur für Produkte/Module, Netto-, Steuer- und Bruttobeträge.
|
||||
3. Lade das generierte PDF über den Supabase JS-Client direkt in das `invoices`-Bucket hoch und speichere die finale URL fehlerfrei in `orders.pdf_url`.
|
||||
11
.agents/skills/second-brain/skill.md
Normal file
11
.agents/skills/second-brain/skill.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: second-brain
|
||||
description: Verwaltet und aktualisiert die projektspezifische Wissensdatenbank (Second Brain Wiki) im Ordner `.brain/`.
|
||||
---
|
||||
Du bist der Wissens-Architekt des Projekts. Deine Aufgabe ist es, eine strukturierte Markdown-Wissensdatenbank im Ordner `.brain/` im Projekt-Root zu führen.
|
||||
|
||||
**Deine Kernaufgaben bei Aktivierung:**
|
||||
1. Falls der Ordner `.brain/` oder die `index.md` nicht existieren, lege sie sofort strukturiert an.
|
||||
2. Wenn du Code-Änderungen vornimmst, neue Features baust oder Fehler behebst, aktualisiere die betroffene Dokumentation im `.brain/`-Ordner autonom.
|
||||
3. Dokumentiere hierbei: Technische Entscheidungen (Architecture Decision Records - ADRs), geänderte Tabellen-Strukturen, Edge-Cases in der Business-Logik und API-Schnittstellen.
|
||||
4. Halte alle Einträge extrem präzise, modular und frei von Redundanzen.
|
||||
8
.agents/skills/snapshot-archiver/skill.md
Normal file
8
.agents/skills/snapshot-archiver/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: snapshot-archiver
|
||||
description: Verwaltet das Einfrieren von Kundendaten und Produktkonfigurationen in JSONB bei Bestellabschluss.
|
||||
---
|
||||
Fokus: Datenkonsistenz und Unveränderlichkeit.
|
||||
1. Wenn eine Bestellung (`status = 'pending' -> 'active'`) ausgelöst wird, generiere ein tiefes JSON-Objekt aus `end_customers` und den gewählten `products` + `product_modules`.
|
||||
2. Speichere dieses exakt in `orders.customer_data` und `orders.order_data`.
|
||||
3. Schreibe Abfragen für historische Bestellungen (`/my-orders`) so, dass sie AUSSCHLIESSLICH die JSONB-Felder auslesen und KEINE Joins zu den Live-Tabellen machen.
|
||||
8
.agents/skills/supabase-rls/skill.md
Normal file
8
.agents/skills/supabase-rls/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: supabase-rls
|
||||
description: Überprüft SQL-Migrationen und Supabase-Abfragen auf Einhaltung der CASPOS RLS-Sicherheitsregeln.
|
||||
---
|
||||
Du bist ein PostgreSQL-Sicherheitsexperte. Wann immer neue Tabellen angelegt oder Abfragen geschrieben werden, stelle sicher:
|
||||
1. Jede neue Tabelle besitzt `ALTER TABLE ... ENABLE ROW LEVEL SECURITY;`.
|
||||
2. Mandanten-Abfragen (`end_customers`, `orders`) MÜSSEN die `company_id` bzw. `partner_id` über `auth.uid()` und die `public.users`-Hilfstabelle validieren.
|
||||
3. Admins (`role = 'admin'`) müssen alle RLS-Sperren umgehen können (Verwende ein deklaratives Sicherheits-Policy-Muster).
|
||||
41
.agents/skills/uiux-designer/skill.md
Normal file
41
.agents/skills/uiux-designer/skill.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: uiux-designer
|
||||
description: "Use this skill when designing UI components, choosing color palettes, implementing responsive layouts, or reviewing code for UX issues. For landing pages, dashboards, e-commerce, SaaS, and mobile apps. Provides 50+ design styles, 97 color palettes, 57 font pairings, and stack-specific guidelines for React, Vue, Next.js, Flutter, SwiftUI, and more."
|
||||
---
|
||||
|
||||
# UIUX Designer - Design Intelligence
|
||||
|
||||
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 97 color palettes, 57 font pairings, 99 UX guidelines, and 25 chart types across 12 technology stacks. Searchable database with priority-based recommendations.
|
||||
|
||||
## Overview
|
||||
|
||||
Reference these guidelines when:
|
||||
- Designing new UI components or pages
|
||||
- Choosing color palettes and typography
|
||||
- Reviewing code for UX issues
|
||||
- Building landing pages or dashboards
|
||||
- Implementing accessibility requirements
|
||||
|
||||
## Protocols
|
||||
|
||||
When user requests UI/UX work (design, build, create, implement, review, fix, improve), follow this workflow:
|
||||
|
||||
### Step 1: Analyze User Requirements
|
||||
- Product type: SaaS, e-commerce, portfolio, dashboard, landing page, etc.
|
||||
- Style keywords: minimal, playful, professional, elegant, dark mode, etc.
|
||||
- Industry: healthcare, fintech, gaming, education, etc.
|
||||
- Stack: React, Vue, Next.js, or default to html-tailwind
|
||||
|
||||
### Step 2: Generate Design System (REQUIRED)
|
||||
python3 .agents/skills/uiux-designer/scripts/search.py "<keywords>" --design-system -p "Project Name"
|
||||
|
||||
### Step 3: Supplement with Detailed Searches
|
||||
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
|
||||
|
||||
## 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).
|
||||
8
.agents/skills/wizard-validator/skill.md
Normal file
8
.agents/skills/wizard-validator/skill.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: wizard-validator
|
||||
description: Generiert und validiert die server- und clientseitige Logik für Produkt-Abhängigkeiten und Ausschlüsse.
|
||||
---
|
||||
Du bist Logik-Architekt für den CASPOS Bestell-Wizard. Wenn du Code für den `/order`-Pfad schreibst:
|
||||
1. Validiere, dass bei `requirements` (UUID[]) alle verknüpften Produkte/Module aktiv sind.
|
||||
2. Blockiere die Auswahl sofort im UI und in der Server Action, wenn eine UUID aus `exclusions` aktiv ist.
|
||||
3. Stelle sicher, dass die Pflichtkategorien (`is_required`) immer eine Auswahl erzwingen.
|
||||
19
.brain/adr-log.md
Normal file
19
.brain/adr-log.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Architecture Decision Records (ADR) Log
|
||||
|
||||
## ADR-001: RLS-Policies und Schema-Fix
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-07-06
|
||||
- **Context**: RLS-Sicherheitsprüfung ergab Sicherheitsrisiken und einen Syntaxfehler durch den Verweis auf die nicht existierende Tabelle `public.partners`.
|
||||
- **Decision**:
|
||||
- Die ungültige RLS-Policy auf `public.partners` wurde entfernt.
|
||||
- `public.companies` wird stattdessen korrekt durch `Admin manage companies` geschützt.
|
||||
- **Consequences**: Behebt Migrations-Fehler im Staging-Server, CI/CD-Pipeline baut wieder fehlerfrei.
|
||||
|
||||
## ADR-002: Entfernung ungenutzter Lizenztabellen
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-07-06
|
||||
- **Context**: Die Tabellen `pos_modules` und `licenses` wurden von einem alten bzw. externen Service genutzt. Im aktuellen Webshop-Frontend werden sie nicht benötigt und führen zu unnötiger RLS- und Integritäts-Komplexität.
|
||||
- **Decision**:
|
||||
- Erstellen einer SQL-Migration zum Löschen (`DROP`) von `licenses` und `pos_modules`.
|
||||
- Anpassung der Integritätsprüfungs-Funktionen `check_database_integrity` und `repair_database_schema` zur Entfernung dieser Relationen.
|
||||
- **Consequences**: Schlankere Datenbankstruktur, RLS-Komplexität verringert und sauberere Codebasis.
|
||||
5
.brain/index.md
Normal file
5
.brain/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Second Brain Wiki
|
||||
|
||||
- [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"
|
||||
}
|
||||
```
|
||||
|
||||
64
.brain/supabase-schema.md
Normal file
64
.brain/supabase-schema.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Supabase Schema
|
||||
|
||||
## `companies` (Unternehmen)
|
||||
- Repräsentiert die Partner-Unternehmen (Retailer).
|
||||
- `id` (UUID, Primary Key)
|
||||
- `name` (TEXT)
|
||||
- `street`, `zip`, `city`, `email` (Adressdaten)
|
||||
|
||||
## `users` (Systembenutzer)
|
||||
- Erweitert die Authentifizierungsdaten aus `auth.users`.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
|
||||
- `company_id` (UUID, References `public.companies(id)`)
|
||||
|
||||
## `profiles` (Benutzerprofile)
|
||||
- Stammdaten der einzelnen Benutzer.
|
||||
- `id` (UUID, References `auth.users(id)`)
|
||||
- `first_name`, `last_name`, `email` etc.
|
||||
|
||||
## `end_customers` (Endkunden)
|
||||
- `id` (UUID, Primary Key)
|
||||
- `partner_id` (UUID, References `public.companies(id)`)
|
||||
- `company_name` (TEXT)
|
||||
- `first_name`, `last_name`, `street`, `zip`, `city`, `email`
|
||||
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner`
|
||||
- `is_anonymized` (BOOLEAN)
|
||||
|
||||
## `products` (Katalogprodukte)
|
||||
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
|
||||
- `requirements` (UUID[])
|
||||
- `exclusions` (UUID[])
|
||||
|
||||
## `product_modules` (Zusatzmodule)
|
||||
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
|
||||
- `requirements` (UUID[])
|
||||
- `exclusions` (UUID[])
|
||||
|
||||
## `orders` (Bestellungen / Anfragen)
|
||||
- `id` (UUID, Primary Key)
|
||||
- `user_id` (UUID, References `auth.users(id)`)
|
||||
- `order_number` (TEXT)
|
||||
- `order_hash` (TEXT)
|
||||
- `end_customer_id` (UUID, References `end_customers(id)`)
|
||||
- `total_price` (DECIMAL)
|
||||
- `customer_data` (JSONB)
|
||||
- `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)
|
||||
@@ -43,6 +43,7 @@ ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV IS_DOCKER=true
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Initialize Supabase if needed and ensure default admin user exists
|
||||
# Requires environment variables:
|
||||
# SUPABASE_URL – base URL of Supabase instance (e.g., http://192.168.1.132:8000)
|
||||
# SUPABASE_SERVICE_ROLE_KEY – service role secret key with admin privileges
|
||||
# DEFAULT_USER_EMAIL – email for the default user (default: info@hephex.de)
|
||||
# DEFAULT_USER_PASSWORD – password for the default user (default: Kathi2022!)
|
||||
|
||||
DEFAULT_EMAIL="${DEFAULT_USER_EMAIL:-info@hephex.de}"
|
||||
DEFAULT_PASSWORD="${DEFAULT_USER_PASSWORD:-Kathi2022!}"
|
||||
|
||||
if [[ -z "$SUPABASE_URL" || -z "$SUPABASE_SERVICE_ROLE_KEY" ]]; then
|
||||
echo "Supabase URL or Service Role Key not set – skipping user creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the user already exists
|
||||
EXISTING=$(curl -s "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
|
||||
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
|
||||
-H "Content-Type: application/json" | grep -i "${DEFAULT_EMAIL}")
|
||||
|
||||
if [[ -n "$EXISTING" ]]; then
|
||||
echo "Default user ${DEFAULT_EMAIL} already exists."
|
||||
else
|
||||
echo "Creating default user ${DEFAULT_EMAIL}..."
|
||||
curl -s -X POST "${SUPABASE_URL}/auth/v1/admin/users?apikey=${SUPABASE_SERVICE_ROLE_KEY}" \
|
||||
-H "Authorization: Bearer ${SUPABASE_SERVICE_ROLE_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\": \"${DEFAULT_EMAIL}\", \"password\": \"${DEFAULT_PASSWORD}\", \"email_confirmed\": true}"
|
||||
echo "User created."
|
||||
fi
|
||||
echo "Supabase default user creation disabled – setup wizard will handle user initialization."
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
346
shop/app/account-kunden/AccountSettingsForm.tsx
Normal file
346
shop/app/account-kunden/AccountSettingsForm.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { updateUserProfile, updateCompanyDetails } from '@/lib/actions/users';
|
||||
import { updatePassword } from '@/lib/actions/auth';
|
||||
import { Save, KeyRound, Loader2 } from 'lucide-react';
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
userId: string;
|
||||
profile: any;
|
||||
company: any;
|
||||
}
|
||||
|
||||
export function AccountSettingsForm({ userId, profile, company }: AccountSettingsFormProps) {
|
||||
// Profil State
|
||||
const [firstName, setFirstName] = useState(profile?.first_name || '');
|
||||
const [lastName, setLastName] = useState(profile?.last_name || '');
|
||||
const [profileMessage, setProfileMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [isUpdatingProfile, setIsUpdatingProfile] = useState(false);
|
||||
|
||||
// Company State
|
||||
const [street, setStreet] = useState(company?.street || '');
|
||||
const [zip, setZip] = useState(company?.zip || '');
|
||||
const [city, setCity] = useState(company?.city || '');
|
||||
const [companyEmail, setCompanyEmail] = useState(company?.email || '');
|
||||
const [companyMessage, setCompanyMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [isUpdatingCompany, setIsUpdatingCompany] = useState(false);
|
||||
|
||||
// Passwort State
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordMessage, setPasswordMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [isUpdatingPassword, setIsUpdatingPassword] = useState(false);
|
||||
|
||||
// Profil speichern
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMessage(null);
|
||||
setIsUpdatingProfile(true);
|
||||
|
||||
try {
|
||||
const res = await updateUserProfile(userId, {
|
||||
first_name: firstName || null,
|
||||
last_name: lastName || null,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setProfileMessage({ type: 'success', text: 'Persönliche Daten erfolgreich aktualisiert.' });
|
||||
} else {
|
||||
setProfileMessage({ type: 'error', text: res.error || 'Fehler beim Aktualisieren des Profils.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfileMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
|
||||
} finally {
|
||||
setIsUpdatingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Company speichern
|
||||
const handleUpdateCompany = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!company?.id) return;
|
||||
setCompanyMessage(null);
|
||||
setIsUpdatingCompany(true);
|
||||
|
||||
try {
|
||||
const res = await updateCompanyDetails(company.id, {
|
||||
street: street || null,
|
||||
zip: zip || null,
|
||||
city: city || null,
|
||||
email: companyEmail || null,
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
setCompanyMessage({ type: 'success', text: 'Unternehmensdaten erfolgreich aktualisiert.' });
|
||||
} else {
|
||||
setCompanyMessage({ type: 'error', text: res.error || 'Fehler beim Aktualisieren der Unternehmensdaten.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCompanyMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
|
||||
} finally {
|
||||
setIsUpdatingCompany(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort speichern
|
||||
const handleUpdatePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordMessage(null);
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setPasswordMessage({ type: 'error', text: 'Das Passwort muss mindestens 6 Zeichen lang sein.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMessage({ type: 'error', text: 'Die Passwörter stimmen nicht überein.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingPassword(true);
|
||||
|
||||
try {
|
||||
const res = await updatePassword(newPassword);
|
||||
if (res.success) {
|
||||
setPasswordMessage({ type: 'success', text: 'Passwort erfolgreich geändert.' });
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} else {
|
||||
setPasswordMessage({ type: 'error', text: res.error || 'Fehler beim Ändern des Passworts.' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPasswordMessage({ type: 'error', text: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' });
|
||||
} finally {
|
||||
setIsUpdatingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 1. Persönliche Daten */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Persönliche Daten</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Aktualisieren Sie Ihren Vor- und Nachnamen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Vorname</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Nachname</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="Mustermann"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileMessage && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm border ${
|
||||
profileMessage.type === 'success'
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{profileMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isUpdatingProfile} className="gap-2">
|
||||
{isUpdatingProfile ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" /> Profil speichern
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Unternehmensdaten */}
|
||||
{company && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Unternehmensdaten</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Verwalten Sie die Adressdaten Ihres Partner-Unternehmens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpdateCompany} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="companyName">Firmenname (nicht änderbar)</Label>
|
||||
<Input
|
||||
id="companyName"
|
||||
value={company.name}
|
||||
disabled
|
||||
className="bg-slate-950/50 border-white/5 text-slate-400 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="street">Straße & Hausnummer</Label>
|
||||
<Input
|
||||
id="street"
|
||||
value={street}
|
||||
onChange={(e) => setStreet(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="Musterstraße 12"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zip">Postleitzahl</Label>
|
||||
<Input
|
||||
id="zip"
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="Musterstadt"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="companyEmail">E-Mail für Rechnungen / Anfragen</Label>
|
||||
<Input
|
||||
id="companyEmail"
|
||||
type="email"
|
||||
value={companyEmail}
|
||||
onChange={(e) => setCompanyEmail(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="partner@firma.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companyMessage && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm border ${
|
||||
companyMessage.type === 'success'
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{companyMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isUpdatingCompany} className="gap-2">
|
||||
{isUpdatingCompany ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" /> Firmendaten speichern
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 3. Sicherheit / Passwort ändern */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Passwort ändern</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Geben Sie ein neues Passwort für Ihren Zugang ein.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">Neues Passwort</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Passwort bestätigen</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="bg-slate-950 border-white/10 focus:border-primary text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordMessage && (
|
||||
<div
|
||||
className={`p-3 rounded-lg text-sm border ${
|
||||
passwordMessage.type === 'success'
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-400'
|
||||
: 'bg-red-500/10 border-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{passwordMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isUpdatingPassword} className="gap-2">
|
||||
{isUpdatingPassword ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Ändern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="w-4 h-4" /> Passwort aktualisieren
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
shop/app/account-kunden/page.tsx
Normal file
131
shop/app/account-kunden/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, User as UserIcon, Building2, ShieldAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { AccountSettingsForm } from './AccountSettingsForm';
|
||||
|
||||
export default async function AccountKundenPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login?next=/account-kunden');
|
||||
}
|
||||
|
||||
// 1. Hole User aus custom users Tabelle
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// 2. Hole Profil-Daten
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
// 3. Hole Company-Daten falls vorhanden
|
||||
let company = null;
|
||||
if (dbUser?.company_id) {
|
||||
const { data: companyData } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('id', dbUser.company_id)
|
||||
.single();
|
||||
company = companyData;
|
||||
}
|
||||
|
||||
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">
|
||||
<UserIcon className="w-8 h-8 text-primary" />
|
||||
Account Einstellungen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Verwalten Sie Ihre persönlichen Profildaten, Unternehmensdaten und Sicherheitseinstellungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* Linke Infospalte */}
|
||||
<div className="space-y-6">
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5 text-primary" />
|
||||
Benutzer-Info
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400 text-xs">
|
||||
Ihr aktueller Systemstatus
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500 block text-xs uppercase tracking-wider">E-Mail-Adresse</span>
|
||||
<span className="text-white font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 block text-xs uppercase tracking-wider">Rolle</span>
|
||||
<span className="text-amber-400 font-bold uppercase text-xs tracking-wider bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||
{dbUser?.role || 'partner'}
|
||||
</span>
|
||||
</div>
|
||||
{company && (
|
||||
<div>
|
||||
<span className="text-slate-500 block text-xs uppercase tracking-wider">Unternehmen</span>
|
||||
<span className="text-white font-medium flex items-center gap-1.5 mt-1">
|
||||
<Building2 className="w-4 h-4 text-slate-400" />
|
||||
{company.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!dbUser?.company_id && (
|
||||
<Card className="border-red-500/20 bg-red-500/5">
|
||||
<CardContent className="p-4 flex gap-3 text-red-400 text-sm">
|
||||
<ShieldAlert className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-bold">Kein Unternehmen zugewiesen</p>
|
||||
<p className="text-xs text-red-400/80 mt-1">
|
||||
Wenden Sie sich an einen Administrator, um Ihrem Account ein Partner-Unternehmen zuzuweisen.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rechte Formularspalte */}
|
||||
<div className="md:col-span-2">
|
||||
<AccountSettingsForm
|
||||
userId={user.id}
|
||||
profile={profile}
|
||||
company={company}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
shop/app/actions/checkout.ts
Normal file
314
shop/app/actions/checkout.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createHash } from 'crypto';
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
|
||||
import { getProducts, getCategories } from '@/lib/actions/products';
|
||||
import { sendMail } from '@/utils/mail';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
const rand = Math.floor(10000 + Math.random() * 90000);
|
||||
return `${prefix}-${year}-${rand}`;
|
||||
}
|
||||
|
||||
function hashOrderPayload(endCustomerId: string, items: any[]): string {
|
||||
const str = endCustomerId + JSON.stringify(items);
|
||||
return createHash('sha256').update(str).digest('hex');
|
||||
}
|
||||
|
||||
// Asynchroner Hintergrund-Worker für Post-Processing (PDF-Erstellung und E-Mail)
|
||||
async function triggerPostProcessing(orderId: string, supabase: any, customerSnapshot: any, orderSnapshot: any, type: string, email: string) {
|
||||
try {
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single();
|
||||
|
||||
if (error || !order) {
|
||||
console.error(`Post-Processing failed: Order ${orderId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF generieren
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
})
|
||||
);
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
|
||||
|
||||
if (!uploadError) {
|
||||
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
|
||||
} else {
|
||||
console.error(`PDF Upload failed for order ${orderId}:`, uploadError);
|
||||
}
|
||||
|
||||
// E-Mail versenden
|
||||
if (email) {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19;
|
||||
const items = orderSnapshot.items ?? [];
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
let totalDetailsText = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
}
|
||||
|
||||
let totalDetailsHtml = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items);
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: `Anfragebestätigung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error in post-processing for order ${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkoutAction(params: {
|
||||
items: any[];
|
||||
endCustomerId: string;
|
||||
customerProfile?: any;
|
||||
}) {
|
||||
const { items, endCustomerId, customerProfile } = params;
|
||||
const supabase = await createClient();
|
||||
|
||||
// 1. Session & Auth Check
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.');
|
||||
}
|
||||
|
||||
// 2. Idempotency Guard
|
||||
const orderHash = hashOrderPayload(endCustomerId, items);
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
||||
|
||||
const { data: existingOrders } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('order_hash', orderHash)
|
||||
.gte('created_at', fiveMinutesAgo);
|
||||
|
||||
if (existingOrders && existingOrders.length > 0) {
|
||||
return { success: true, orderIds: existingOrders.map(o => o.id), duplicated: true };
|
||||
}
|
||||
|
||||
// Fetch End Customer Stammdaten
|
||||
const { data: endCustomer, error: customerErr } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.eq('id', endCustomerId)
|
||||
.single();
|
||||
|
||||
if (customerErr || !endCustomer) {
|
||||
throw new Error('Endkunde nicht gefunden.');
|
||||
}
|
||||
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile || {}, endCustomer);
|
||||
|
||||
const products = await getProducts();
|
||||
const categories = await getCategories();
|
||||
|
||||
// 3. Checkout Split
|
||||
const purchaseItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||
);
|
||||
const subscriptionItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
|
||||
);
|
||||
|
||||
const orderIds: string[] = [];
|
||||
|
||||
const createOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
|
||||
if (groupItems.length === 0) return;
|
||||
|
||||
const orderItemsList: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
item.selections,
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
items: orderItemsList,
|
||||
total: total,
|
||||
tax_rate: 19,
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
};
|
||||
|
||||
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId,
|
||||
total_price: total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
type: type,
|
||||
payment_method: paymentMethod,
|
||||
status: 'pending'
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) throw orderError;
|
||||
orderIds.push(order.id);
|
||||
|
||||
// Entkoppeltes Post-Processing (asynchroner Aufruf)
|
||||
if (user.email) {
|
||||
Promise.resolve().then(() => {
|
||||
triggerPostProcessing(order.id, supabase, customerSnapshot, orderSnapshot, type, user.email!);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await createOrderGroup(purchaseItems, 'purchase', 'invoice');
|
||||
await createOrderGroup(subscriptionItems, 'subscription', 'sepa');
|
||||
|
||||
return { success: true, orderIds, duplicated: false };
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export default function CompanyCustomersPage() {
|
||||
setZip(customer ? customer.zip || '' : '')
|
||||
setCity(customer ? customer.city || '' : '')
|
||||
setVatId(customer ? customer.vat_id || '' : '')
|
||||
setPartnerId(customer ? customer.partner_id || '' : (partners.length > 0 ? partners[0].id : ''))
|
||||
setPartnerId(companyId)
|
||||
setEmail(customer ? customer.email || '' : '')
|
||||
setError(null)
|
||||
setIsOpen(true)
|
||||
@@ -106,15 +106,11 @@ export default function CompanyCustomersPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!partnerId) {
|
||||
setError('Bitte weisen Sie einen Partner zu.')
|
||||
return
|
||||
}
|
||||
setLoadingAction(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {
|
||||
partner_id: partnerId,
|
||||
partner_id: companyId,
|
||||
company_name: companyName.trim(),
|
||||
first_name: firstName.trim() || undefined,
|
||||
last_name: lastName.trim() || undefined,
|
||||
@@ -192,7 +188,7 @@ export default function CompanyCustomersPage() {
|
||||
onClick={() => handleOpenDialog()}
|
||||
disabled={loading || partners.length === 0}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold"
|
||||
title={partners.length === 0 ? 'Es müssen zuerst Partner für diese Firma existieren' : ''}
|
||||
title={partners.length === 0 ? 'Es müssen zuerst Benutzer für diese Firma existieren' : ''}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Endkunde anlegen
|
||||
</Button>
|
||||
@@ -415,21 +411,14 @@ export default function CompanyCustomersPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cust-partner">Zugeordneter Partner *</Label>
|
||||
<select
|
||||
<Label htmlFor="cust-partner">Zugeordnetes Unternehmen</Label>
|
||||
<Input
|
||||
id="cust-partner"
|
||||
required
|
||||
value={partnerId}
|
||||
onChange={(e) => setPartnerId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-slate-200"
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-slate-950 text-slate-900 dark:text-white">Partner auswählen...</option>
|
||||
{partners.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-slate-950 text-slate-900 dark:text-white">
|
||||
{p.name} ({p.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
type="text"
|
||||
disabled
|
||||
value={company ? company.name : ''}
|
||||
className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,14 +14,14 @@ 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[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [editingCompany, setEditingCompany] = useState<any>(null)
|
||||
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [street, setStreet] = useState('')
|
||||
const [zip, setZip] = useState('')
|
||||
@@ -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 Partner werden getrennt.`)) return
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteCompany(id)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const filteredCompanies = companies.filter((c) => {
|
||||
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -123,14 +134,34 @@ export default function CompaniesPage() {
|
||||
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
variant="outline"
|
||||
className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white"
|
||||
>
|
||||
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Partner importieren
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Firmen-Tabelle */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||
<div className="relative mb-6 max-w-sm">
|
||||
@@ -214,11 +245,14 @@ export default function CompaniesPage() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={deletingId === company.id}
|
||||
onClick={() => handleDelete(company.id, company.name)}
|
||||
onClick={() => {
|
||||
setDeleteConfirmId(company.id)
|
||||
setDeleteConfirmName(company.name)
|
||||
}}
|
||||
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="Firma löschen"
|
||||
>
|
||||
@@ -259,7 +293,7 @@ export default function CompaniesPage() {
|
||||
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company-street">Straße & Hausnummer</Label>
|
||||
<Input
|
||||
@@ -329,6 +363,44 @@ export default function CompaniesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog: Delete Confirmation */}
|
||||
<Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
|
||||
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Firma löschen</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||
Möchten Sie die Firma <strong>{deleteConfirmName}</strong> wirklich löschen? Alle zugewiesenen Benutzer werden getrennt. Dieser Schritt kann nicht rückgängig gemacht werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (deleteConfirmId) {
|
||||
setDeletingId(deleteConfirmId)
|
||||
const targetId = deleteConfirmId
|
||||
setDeleteConfirmId(null)
|
||||
try {
|
||||
await deleteCompany(targetId)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white font-semibold"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, Upload, Database, AlertTriangle, Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const [demoActive, setDemoActive] = useState(true);
|
||||
@@ -13,11 +18,64 @@ export default function AdminSettings() {
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// LicServer Config State
|
||||
const [licUrl, setLicUrl] = useState('');
|
||||
const [licKey, setLicKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [licSaving, setLicSaving] = useState(false);
|
||||
const [licTesting, setLicTesting] = useState(false);
|
||||
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [licMsg, setLicMsg] = useState('');
|
||||
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
}, []);
|
||||
async function checkAccess() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userData || userData.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
|
||||
// Load current LicServer config from DB
|
||||
try {
|
||||
const { data: licRow } = await supabase
|
||||
.from('settings')
|
||||
.select('licserver_base_url, licserver_api_key')
|
||||
.eq('id', 'licserver')
|
||||
.single();
|
||||
if (licRow) {
|
||||
setLicUrl(licRow.licserver_base_url || '');
|
||||
setLicKey(licRow.licserver_api_key || '');
|
||||
}
|
||||
} catch (dbErr) {
|
||||
console.error("Fehler beim Laden der LicServer-Einstellungen:", dbErr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler bei checkAccess in Einstellungen:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAccess();
|
||||
}, [router]);
|
||||
|
||||
const toggleDemo = (checked: boolean) => {
|
||||
setDemoActive(checked);
|
||||
@@ -32,7 +90,7 @@ export default function AdminSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/db-backup');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -85,6 +143,10 @@ export default function AdminSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
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 */}
|
||||
@@ -122,11 +184,10 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
<div className={`p-4 rounded-lg text-sm border ${statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
@@ -143,8 +204,8 @@ export default function AdminSettings() {
|
||||
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
@@ -173,9 +234,9 @@ export default function AdminSettings() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
disabled={exporting || importing}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
@@ -189,8 +250,8 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 dark:border-white/10"
|
||||
@@ -205,6 +266,125 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* LicServer Konfiguration */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-violet-500" />
|
||||
LicServer Konfiguration
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Verbindungseinstellungen zum CASPOS Lizenzserver.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{licMsg && (
|
||||
<div className={`p-3 rounded-lg text-sm border ${licMsgType === 'success'
|
||||
? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400'
|
||||
: 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{licMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection Test Result */}
|
||||
{licStatus && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm border ${licStatus.ok
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||
}`}>
|
||||
{licStatus.ok
|
||||
? <CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
: <XCircle className="w-4 h-4 shrink-0" />}
|
||||
{licStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Base URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-url" className="text-xs font-medium flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="lic-url"
|
||||
value={licUrl}
|
||||
onChange={e => setLicUrl(e.target.value)}
|
||||
placeholder="http://192.168.178.174:9980"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-key" className="text-xs font-medium flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="lic-key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={licKey}
|
||||
onChange={e => setLicKey(e.target.value)}
|
||||
placeholder="Ihr X-Api-Key"
|
||||
className="text-sm font-mono pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(v => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button
|
||||
id="lic-save-btn"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicSaving(true);
|
||||
setLicMsg('');
|
||||
setLicStatus(null);
|
||||
const res = await saveLicServerConfig(licUrl, licKey);
|
||||
setLicMsgType(res.success ? 'success' : 'error');
|
||||
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
|
||||
setLicSaving(false);
|
||||
}}
|
||||
className="bg-violet-600 hover:bg-violet-500 text-white"
|
||||
>
|
||||
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
id="lic-test-btn"
|
||||
variant="outline"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicTesting(true);
|
||||
setLicStatus(null);
|
||||
// Save first, then test
|
||||
await saveLicServerConfig(licUrl, licKey);
|
||||
const result = await testLicServerConnection();
|
||||
setLicStatus(result);
|
||||
setLicTesting(false);
|
||||
}}
|
||||
className="border-white/10"
|
||||
>
|
||||
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2 } from 'lucide-react'
|
||||
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2, Sparkles } from 'lucide-react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
|
||||
import { DemoWrapper } from '@/components/DemoWrapper'
|
||||
import { AdminNavLink } from '@/components/admin/admin-nav-link'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -20,10 +21,13 @@ export default async function AdminLayout({
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!userData || userData.role !== 'admin') {
|
||||
const userRole = userData?.role
|
||||
if (!userData || (userRole !== 'admin' && userRole !== 'verwaltung')) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const isVerwaltung = userRole === 'verwaltung'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50 dark:bg-[#020617] text-slate-900 dark:text-white">
|
||||
{/* Sidebar */}
|
||||
@@ -36,51 +40,60 @@ export default async function AdminLayout({
|
||||
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<nav className="flex-1 px-4 space-y-2 py-4">
|
||||
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<AdminNavLink href="/admin">
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/products">
|
||||
<Package className="w-5 h-5" />
|
||||
Produkte
|
||||
</Link>
|
||||
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/categories">
|
||||
<LayoutGrid className="w-5 h-5" />
|
||||
Kategorien
|
||||
</Link>
|
||||
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/wysiwyg">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Order-Wizard
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/orders">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Bestellungen
|
||||
</Link>
|
||||
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/users">
|
||||
<Users className="w-5 h-5" />
|
||||
Benutzer
|
||||
</Link>
|
||||
</AdminNavLink>
|
||||
|
||||
<Link href="/admin/companies" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<AdminNavLink href="/admin/companies">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Firmen
|
||||
</Link>
|
||||
</AdminNavLink>
|
||||
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
|
||||
</div>
|
||||
<Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<Settings className="w-5 h-5" />
|
||||
Allgemein
|
||||
</Link>
|
||||
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<Wrench className="w-5 h-5" />
|
||||
SMTP
|
||||
</Link>
|
||||
<Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
|
||||
<Database className="w-5 h-5" />
|
||||
DB-Tools
|
||||
</Link>
|
||||
{!isVerwaltung && (
|
||||
<>
|
||||
<div className="pt-2 pb-1 px-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
|
||||
</div>
|
||||
<AdminNavLink href="/admin/einstellungen">
|
||||
<Settings className="w-5 h-5" />
|
||||
Allgemein
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/settings">
|
||||
<Wrench className="w-5 h-5" />
|
||||
SMTP
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/tools">
|
||||
<Database className="w-5 h-5" />
|
||||
DB-Tools
|
||||
</AdminNavLink>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
|
||||
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">
|
||||
<AdminLogoutButton />
|
||||
<div className="px-3 pt-2 flex items-center gap-2 border-t border-slate-200 dark:border-white/5 opacity-60">
|
||||
|
||||
@@ -8,7 +8,7 @@ export default async function AdminOrdersPage() {
|
||||
<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 Angebotsbestätigungen zu.</p>
|
||||
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Annfragesbestätigungen zu.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function AdminOrdersPage() {
|
||||
|
||||
async function OrdersData() {
|
||||
const adminDb = createAdminClient()
|
||||
|
||||
|
||||
const { data: orders, error } = await adminDb
|
||||
.from('orders')
|
||||
.select('*')
|
||||
|
||||
@@ -14,12 +14,22 @@ async function getKpis() {
|
||||
{ count: userCount },
|
||||
{ count: productCount },
|
||||
] = await Promise.all([
|
||||
adminDb.from('orders').select('total_price, status'),
|
||||
adminDb.from('orders').select('total_price, status, created_at'),
|
||||
adminDb.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('products').select('*', { count: 'exact', head: true }),
|
||||
])
|
||||
|
||||
const totalRevenue = (orders ?? []).reduce((sum, o) => sum + Number(o.total_price), 0)
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth()
|
||||
|
||||
const completedOrdersThisMonth = (orders ?? []).filter(o => {
|
||||
if (o.status !== 'completed') return false
|
||||
const orderDate = new Date(o.created_at)
|
||||
return orderDate.getFullYear() === currentYear && orderDate.getMonth() === currentMonth
|
||||
})
|
||||
|
||||
const totalRevenue = completedOrdersThisMonth.reduce((sum, o) => sum + Number(o.total_price), 0)
|
||||
const orderCount = (orders ?? []).length
|
||||
|
||||
return { totalRevenue, orderCount, userCount: userCount ?? 0, productCount: productCount ?? 0 }
|
||||
@@ -36,14 +46,14 @@ export default async function AdminDashboard() {
|
||||
<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 gesamt</CardTitle>
|
||||
<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">Aus allen Bestellungen</p>
|
||||
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
// Admin Settings page – view & edit SMTP configuration and send test email
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface Settings {
|
||||
host: string;
|
||||
@@ -17,17 +19,36 @@ export default function SettingsPage() {
|
||||
const [message, setMessage] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
// Load current settings
|
||||
// Load current settings and verify access
|
||||
useEffect(() => {
|
||||
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));
|
||||
}, []);
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
checkAccessAndLoad();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
@@ -69,7 +90,7 @@ export default function SettingsPage() {
|
||||
else setMessage(data.error || 'Failed to send test email');
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8">Loading…</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">
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
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 { createClient } from "@/lib/supabase/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface TableStatus {
|
||||
exists: boolean;
|
||||
@@ -18,12 +20,7 @@ interface TableStatus {
|
||||
}
|
||||
|
||||
interface IntegrityData {
|
||||
tables: {
|
||||
profiles: TableStatus;
|
||||
end_customers: TableStatus;
|
||||
pos_modules: TableStatus;
|
||||
licenses: TableStatus;
|
||||
};
|
||||
tables: Record<string, TableStatus>;
|
||||
errors: {
|
||||
foreign_keys: number;
|
||||
};
|
||||
@@ -34,6 +31,8 @@ export default function AdminToolsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [activeAction, setActiveAction] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const addLog = (message: string) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
@@ -88,8 +87,31 @@ export default function AdminToolsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
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');
|
||||
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="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">
|
||||
|
||||
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
130
shop/app/admin/wysiwyg/components/dependency-popover.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Product, ProductModule } from '@/lib/types'
|
||||
import { Plus, Search, Package, Layers } from 'lucide-react'
|
||||
|
||||
interface DependencyPopoverProps {
|
||||
currentProductId: string
|
||||
assignedIds: string[]
|
||||
allProducts: Product[]
|
||||
allModules: ProductModule[]
|
||||
onAddDependency: (id: string) => void
|
||||
}
|
||||
|
||||
export function DependencyPopover({
|
||||
currentProductId,
|
||||
assignedIds,
|
||||
allProducts,
|
||||
allModules,
|
||||
onAddDependency
|
||||
}: DependencyPopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Filter out the current product, and items already assigned
|
||||
const otherProducts = allProducts.filter(p => p.id !== currentProductId && !assignedIds.includes(p.id))
|
||||
const otherModules = allModules.filter(m => m.product_id !== currentProductId && !assignedIds.includes(m.id))
|
||||
|
||||
// Apply search
|
||||
const filteredProducts = otherProducts.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
|
||||
const filteredModules = otherModules.filter(m => m.name.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-all text-slate-400 hover:text-white"
|
||||
title="Abhängigkeit hinzufügen"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-2 z-50 w-72 bg-slate-900 border border-white/10 rounded-xl shadow-2xl p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Suchen nach Produkt/Modul..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{filteredProducts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Produkte</span>
|
||||
{filteredProducts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
onAddDependency(p.id)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||
>
|
||||
<Package className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||
<span className="text-xs text-white group-hover:text-primary transition-colors">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredModules.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">Module</span>
|
||||
{filteredModules.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
onAddDependency(m.id)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-1.5 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 group"
|
||||
>
|
||||
<Layers className="w-3 h-3 text-slate-500 group-hover:text-primary transition-colors" />
|
||||
<span className="text-xs text-white group-hover:text-primary transition-colors">{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredProducts.length === 0 && filteredModules.length === 0 && (
|
||||
<div className="text-[11px] text-slate-400 text-center py-4">
|
||||
Keine Ergebnisse gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
81
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface InlineInputProps {
|
||||
value: string
|
||||
onSave: (val: string) => Promise<void>
|
||||
className?: string
|
||||
type?: 'text' | 'number'
|
||||
}
|
||||
|
||||
export function InlineInput({ value, onSave, className, type = 'text' }: InlineInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [currentValue, setCurrentValue] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const handleBlur = async () => {
|
||||
setIsEditing(false)
|
||||
const trimmed = currentValue.trim()
|
||||
|
||||
if (type === 'number') {
|
||||
const parsed = parseFloat(trimmed)
|
||||
if (isNaN(parsed) || trimmed === '') {
|
||||
setCurrentValue(value)
|
||||
return
|
||||
}
|
||||
const finalVal = parsed.toString()
|
||||
if (finalVal !== value) {
|
||||
await onSave(finalVal)
|
||||
}
|
||||
} else {
|
||||
if (trimmed === '') {
|
||||
setCurrentValue(value)
|
||||
return
|
||||
}
|
||||
if (trimmed !== value) {
|
||||
await onSave(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBlur()
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
step={type === 'number' ? '0.01' : undefined}
|
||||
value={currentValue}
|
||||
onChange={(e) => setCurrentValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`bg-white/10 text-white border border-primary px-1 rounded outline-none w-full ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => setIsEditing(true)}
|
||||
className={`cursor-pointer hover:bg-white/5 rounded px-1 transition ${className}`}
|
||||
>
|
||||
{value || <span className="italic text-slate-500">Klicken zum Bearbeiten</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
118
shop/app/admin/wysiwyg/components/module-popover.tsx
Normal file
118
shop/app/admin/wysiwyg/components/module-popover.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ProductModule } from '@/lib/types'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
|
||||
interface ModulePopoverProps {
|
||||
assignedModules: ProductModule[]
|
||||
allModules: ProductModule[]
|
||||
onAddModule: (module: ProductModule) => void
|
||||
}
|
||||
|
||||
export function ModulePopover({ assignedModules, allModules, onAddModule }: ModulePopoverProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Filter modules that are not already assigned to this product
|
||||
// We match by name to treat identical modules across products as the same entity
|
||||
const availableModules = allModules.filter(m => {
|
||||
const isAssigned = assignedModules.some(am => am.name.toLowerCase() === m.name.toLowerCase())
|
||||
const matchesSearch = m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(m.description && m.description.toLowerCase().includes(search.toLowerCase()))
|
||||
return !isAssigned && matchesSearch
|
||||
})
|
||||
|
||||
// Deduplicate available modules by name to show a clean list
|
||||
const uniqueAvailableModules: ProductModule[] = []
|
||||
const seenNames = new Set<string>()
|
||||
for (const m of availableModules) {
|
||||
const nameKey = m.name.toLowerCase()
|
||||
if (!seenNames.has(nameKey)) {
|
||||
seenNames.add(nameKey)
|
||||
uniqueAvailableModules.push(m)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={popoverRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-primary/10 text-primary border border-primary/20 hover:bg-primary hover:text-black rounded-lg transition-all"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Modul hinzufügen
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 mt-2 z-50 w-72 bg-slate-900 border border-white/10 rounded-xl shadow-2xl p-3 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Modul suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 pr-1 custom-scrollbar">
|
||||
{uniqueAvailableModules.length > 0 ? (
|
||||
uniqueAvailableModules.map((mod) => (
|
||||
<button
|
||||
key={mod.id}
|
||||
onClick={() => {
|
||||
onAddModule(mod)
|
||||
setIsOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left p-2 hover:bg-white/5 rounded-lg transition-colors group"
|
||||
>
|
||||
<div className="font-medium text-xs text-white group-hover:text-primary transition-colors">
|
||||
{mod.name}
|
||||
</div>
|
||||
{mod.description && (
|
||||
<div className="text-[10px] text-slate-400 line-clamp-1 mt-0.5">
|
||||
{mod.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-primary font-mono mt-1">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-slate-400 text-center py-4">
|
||||
Keine passenden Module gefunden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
shop/app/admin/wysiwyg/page.tsx
Normal file
48
shop/app/admin/wysiwyg/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { WysiwygAdminClient } from './wysiwyg-client'
|
||||
import { Edit, Sparkles } from 'lucide-react'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function WysiwygAdminPage() {
|
||||
return (
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<Edit className="w-8 h-8 text-primary" />
|
||||
WYSIWYG Live-Editor
|
||||
</h2>
|
||||
<p className="text-slate-300">
|
||||
Kategorien und Produkte visuell im Wizard-Layout bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
|
||||
<WysiwygDataWrapper />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
async function WysiwygDataWrapper() {
|
||||
const products = await getProducts().catch(() => [])
|
||||
const categories = await getCategories().catch(() => [])
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data: allModules } = await supabase
|
||||
.from('product_modules')
|
||||
.select('*')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
return (
|
||||
<WysiwygAdminClient
|
||||
initialProducts={products}
|
||||
initialCategories={categories}
|
||||
allModules={allModules || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
450
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
450
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Category, Product, ProductModule } from '@/lib/types'
|
||||
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory, addProductDependency, removeProductDependency } from '@/lib/actions/products'
|
||||
import { InlineInput } from './components/inline-input'
|
||||
import { ModulePopover } from './components/module-popover'
|
||||
import { DependencyPopover } from './components/dependency-popover'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
export function WysiwygAdminClient({
|
||||
initialCategories,
|
||||
initialProducts,
|
||||
allModules
|
||||
}: {
|
||||
initialCategories: Category[]
|
||||
initialProducts: Product[]
|
||||
allModules: ProductModule[]
|
||||
}) {
|
||||
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
|
||||
const [categories, setCategories] = useState(initialCategories)
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleDeleteCategory = async (catId: string) => {
|
||||
// Cascade-Check: check if there are products belonging to this category
|
||||
const categoryProducts = products.filter(p => p.category_id === catId)
|
||||
if (categoryProducts.length > 0) {
|
||||
alert(`Diese Kategorie enthält noch ${categoryProducts.length} Produkte. Bitte lösche oder verschiebe diese zuerst!`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm('Möchten Sie diese Kategorie wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteCategory(catId)
|
||||
setCategories(categories.filter(c => c.id !== catId))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('Fehler beim Löschen der Kategorie.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteProduct = async (prodId: string) => {
|
||||
if (!window.confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteProduct(prodId)
|
||||
setProducts(products.filter(p => p.id !== prodId))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('Fehler beim Löschen des Produkts.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getDependencyName = (depId: string) => {
|
||||
const matchedProduct = products.find(p => p.id === depId)
|
||||
if (matchedProduct) return matchedProduct.name
|
||||
const matchedModule = allModules.find(m => m.id === depId)
|
||||
if (matchedModule) return `${matchedModule.name} (Modul)`
|
||||
return depId
|
||||
}
|
||||
|
||||
// Filterung der Kategorien nach aktivem Modus
|
||||
const filteredCategories = categories.filter(cat =>
|
||||
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
|
||||
)
|
||||
|
||||
// Filterung der Produkte nach aktivem Modus
|
||||
const filteredProducts = products.filter(p =>
|
||||
mode === 'purchase' ? p.billing_interval === 'one_time' : p.billing_interval === 'monthly'
|
||||
)
|
||||
|
||||
const handleAddCategory = async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newCat = await createCategory({
|
||||
name: 'Neue Kategorie',
|
||||
description: 'Beschreibung hier...',
|
||||
sort_order: categories.length + 1,
|
||||
is_required: false,
|
||||
allow_multiselect: false,
|
||||
free_items_limit: 0,
|
||||
preselect: false,
|
||||
show_in_kauf: mode === 'purchase',
|
||||
show_in_abo: mode === 'subscription',
|
||||
resets_others: false
|
||||
})
|
||||
setCategories([...categories, newCat])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddProduct = async (categoryId: string) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newProd = await createProduct({
|
||||
name: 'Neues Produkt',
|
||||
description: 'Produktbeschreibung...',
|
||||
base_price: 19.99,
|
||||
tax_rate: 19,
|
||||
billing_interval: mode === 'purchase' ? 'one_time' : 'monthly',
|
||||
category_id: categoryId,
|
||||
show_in_kauf: mode === 'purchase',
|
||||
show_in_abo: mode === 'subscription'
|
||||
}, [])
|
||||
setProducts([...products, newProd])
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCategoryToggle = async (catId: string, fields: Partial<Category>) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c))
|
||||
await updateCategory(catId, fields)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-6 max-w-7xl mx-auto space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
|
||||
<div className="flex justify-between items-center border-b border-white/10 pb-4">
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => setMode('purchase')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
mode === 'purchase' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
Kauf-Modus (Einmalig)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('subscription')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition ${
|
||||
mode === 'subscription' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
Abo-Modus (Monatlich)
|
||||
</button>
|
||||
</div>
|
||||
{isPending && <span className="text-xs text-primary animate-pulse font-medium">Speichere Änderungen...</span>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{filteredCategories
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(cat => {
|
||||
const catProducts = filteredProducts.filter(p => p.category_id === cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 shadow-xl">
|
||||
<div className="flex justify-between items-start border-b border-white/5 pb-4">
|
||||
<div className="w-full">
|
||||
<InlineInput
|
||||
value={cat.name}
|
||||
onSave={async (newName) => {
|
||||
await updateCategory(cat.id, { name: newName })
|
||||
setCategories(categories.map(c => c.id === cat.id ? { ...c, name: newName } : c))
|
||||
}}
|
||||
className="text-2xl font-bold text-white block w-full"
|
||||
/>
|
||||
<InlineInput
|
||||
value={cat.description || ''}
|
||||
onSave={async (newDesc) => {
|
||||
await updateCategory(cat.id, { description: newDesc })
|
||||
setCategories(categories.map(c => c.id === cat.id ? { ...c, description: newDesc } : c))
|
||||
}}
|
||||
className="text-slate-400 text-sm mt-1 block w-full"
|
||||
/>
|
||||
|
||||
{/* Kategorie Attribute (Toggles) */}
|
||||
<div className="flex flex-wrap items-center gap-6 mt-3 text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.is_required}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Pflichtauswahl</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.allow_multiselect}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked
|
||||
handleCategoryToggle(cat.id, {
|
||||
allow_multiselect: checked,
|
||||
...(checked ? { preselect: false } : {})
|
||||
})
|
||||
}}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Mehrfachauswahl</span>
|
||||
</label>
|
||||
{!cat.allow_multiselect && (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.preselect}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { preselect: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Vorauswahl</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.resets_others}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { resets_others: e.target.checked })}
|
||||
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<span>Rest zurücksetzen</span>
|
||||
</label>
|
||||
{cat.allow_multiselect && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Freie Artikel Limit:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cat.free_items_limit}
|
||||
onChange={(e) => handleCategoryToggle(cat.id, { free_items_limit: parseInt(e.target.value) || 0 })}
|
||||
className="w-12 rounded border-white/10 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 px-1 py-0.5 text-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteCategory(cat.id)}
|
||||
className="p-2 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded-lg transition-all ml-4 flex-shrink-0"
|
||||
title="Kategorie löschen"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
{catProducts.map(prod => (
|
||||
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 relative group flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<InlineInput
|
||||
value={prod.name}
|
||||
onSave={async (newName) => {
|
||||
await updateProduct(prod.id, { name: newName }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, name: newName } : p))
|
||||
}}
|
||||
className="font-semibold text-white block"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(prod.id)}
|
||||
className="p-1 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
|
||||
title="Produkt löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<InlineInput
|
||||
value={prod.description || ''}
|
||||
onSave={async (newDesc) => {
|
||||
await updateProduct(prod.id, { description: newDesc }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, description: newDesc } : p))
|
||||
}}
|
||||
className="text-xs text-slate-400 block"
|
||||
/>
|
||||
<div className="text-primary font-mono text-sm flex items-center space-x-1">
|
||||
<InlineInput
|
||||
value={prod.base_price.toString()}
|
||||
type="number"
|
||||
onSave={async (newPrice) => {
|
||||
const priceNum = parseFloat(newPrice) || 0
|
||||
await updateProduct(prod.id, { base_price: priceNum }, prod.modules || [])
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, base_price: priceNum } : p))
|
||||
}}
|
||||
className="inline-block"
|
||||
/>
|
||||
<span>€</span>
|
||||
</div>
|
||||
|
||||
{/* Modul-Badges */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Module:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.modules || []).length > 0 ? (
|
||||
(prod.modules || []).map((m) => (
|
||||
<span key={m.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-medium">
|
||||
{m.name}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const updatedModules = (prod.modules || []).filter(mod => mod.id !== m.id)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules } : p))
|
||||
await updateProduct(prod.id, {}, updatedModules)
|
||||
}}
|
||||
className="text-slate-500 hover:text-white font-bold transition-colors ml-0.5"
|
||||
title="Modul entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Module</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Benötigt:</span>
|
||||
<DependencyPopover
|
||||
currentProductId={prod.id}
|
||||
assignedIds={prod.requirements || []}
|
||||
allProducts={products}
|
||||
allModules={allModules}
|
||||
onAddDependency={async (depId) => {
|
||||
const newReqs = [...(prod.requirements || []), depId]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||||
await addProductDependency(prod.id, 'requirements', depId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.requirements || []).length > 0 ? (
|
||||
(prod.requirements || []).map((reqId) => (
|
||||
<span key={reqId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-green-500/10 text-green-400 border border-green-500/20 font-medium animate-fade-in">
|
||||
{getDependencyName(reqId)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newReqs = (prod.requirements || []).filter(id => id !== reqId)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
|
||||
await removeProductDependency(prod.id, 'requirements', reqId)
|
||||
}}
|
||||
className="text-green-600 hover:text-green-400 font-bold transition-colors ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Anforderungen</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exclusions */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Schließt aus:</span>
|
||||
<DependencyPopover
|
||||
currentProductId={prod.id}
|
||||
assignedIds={prod.exclusions || []}
|
||||
allProducts={products}
|
||||
allModules={allModules}
|
||||
onAddDependency={async (depId) => {
|
||||
const newExcs = [...(prod.exclusions || []), depId]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||||
await addProductDependency(prod.id, 'exclusions', depId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(prod.exclusions || []).length > 0 ? (
|
||||
(prod.exclusions || []).map((exId) => (
|
||||
<span key={exId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium animate-fade-in">
|
||||
{getDependencyName(exId)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newExcs = (prod.exclusions || []).filter(id => id !== exId)
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
|
||||
await removeProductDependency(prod.id, 'exclusions', exId)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-400 font-bold transition-colors ml-0.5"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 italic">Keine Ausschlüsse</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modul hinzufügen Popover */}
|
||||
<div className="mt-3 pt-2 border-t border-white/5">
|
||||
<ModulePopover
|
||||
assignedModules={prod.modules || []}
|
||||
allModules={allModules}
|
||||
onAddModule={async (mod) => {
|
||||
const newMod = {
|
||||
name: mod.name,
|
||||
description: mod.description,
|
||||
price: mod.price,
|
||||
requirements: mod.requirements || [],
|
||||
exclusions: mod.exclusions || [],
|
||||
has_quantity: mod.has_quantity || false
|
||||
}
|
||||
const updatedModules = [...(prod.modules || []), newMod]
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules as any } : p))
|
||||
await updateProduct(prod.id, {}, updatedModules)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => handleAddProduct(cat.id)}
|
||||
className="flex flex-col items-center justify-center p-4 border border-dashed border-white/20 hover:border-primary/50 rounded-xl text-slate-400 hover:text-primary transition h-full min-h-[140px]"
|
||||
>
|
||||
<span className="text-2xl mb-1">+</span>
|
||||
<span className="text-xs font-medium">Produkt hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={handleAddCategory}
|
||||
className="w-full py-4 border border-dashed border-white/20 hover:border-primary/50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary transition"
|
||||
>
|
||||
<span className="text-xl mr-2">+</span>
|
||||
<span className="font-medium">Neue Kategorie hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
shop/app/api/admin/orders/[id]/download/route.ts
Normal file
134
shop/app/api/admin/orders/[id]/download/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { InvoicePDF } from "@/components/invoice-pdf";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import React from "react";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const inline = request.nextUrl.searchParams.get("inline") === "true";
|
||||
|
||||
// 1. Auth check
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from("users")
|
||||
.select("role, company_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (!dbUser) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const adminDb = createAdminClient();
|
||||
|
||||
// 2. Get the order from database
|
||||
const { data: order, error } = await adminDb
|
||||
.from("orders")
|
||||
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !order) {
|
||||
return new NextResponse("Order not found", { status: 404 });
|
||||
}
|
||||
|
||||
// 3. Authorization: admin, owner, or company member
|
||||
const isAdmin = dbUser.role === "admin";
|
||||
const isOwner = order.user_id === user.id;
|
||||
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
|
||||
|
||||
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const storageFileName = `ab_${id}.pdf`;
|
||||
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
||||
|
||||
// 4. Try to download from storage if pdf_url is set
|
||||
if (order.pdf_url) {
|
||||
try {
|
||||
const { data: fileData, error: downloadError } = await adminDb
|
||||
.storage
|
||||
.from("invoices")
|
||||
.download(storageFileName);
|
||||
|
||||
if (!downloadError && fileData) {
|
||||
fileBuffer = await fileData.arrayBuffer();
|
||||
}
|
||||
} catch (storageErr) {
|
||||
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. If file not found or pdf_url not set, generate dynamically
|
||||
if (!fileBuffer) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
|
||||
dateStr: formattedDate,
|
||||
customer: order.customer_data,
|
||||
orderData: order.order_data,
|
||||
totalPrice: Number(order.total_price),
|
||||
})
|
||||
);
|
||||
|
||||
// Upload/Upsert to storage bucket
|
||||
const { error: uploadError } = await adminDb.storage
|
||||
.from("invoices")
|
||||
.upload(storageFileName, Buffer.from(buffer), {
|
||||
contentType: "application/pdf",
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error("Failed to upload regenerated PDF:", uploadError);
|
||||
} else {
|
||||
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
|
||||
}
|
||||
|
||||
fileBuffer = buffer;
|
||||
} catch (genErr: any) {
|
||||
console.error("PDF generation failed inside API:", genErr);
|
||||
return new NextResponse("Error generating PDF: " + genErr.message, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Return as PDF response
|
||||
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/pdf");
|
||||
headers.append("Cache-Control", "private, no-cache");
|
||||
if (inline) {
|
||||
headers.append("Content-Disposition", `inline; filename="${downloadFileName}"`);
|
||||
} else {
|
||||
headers.append("Content-Disposition", `attachment; filename="${downloadFileName}"`);
|
||||
}
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Download endpoint failed:", err);
|
||||
return new NextResponse(err.message || "Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
134
shop/app/api/orders/[id]/download/route.ts
Normal file
134
shop/app/api/orders/[id]/download/route.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { InvoicePDF } from "@/components/invoice-pdf";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import React from "react";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const inline = request.nextUrl.searchParams.get("inline") === "true";
|
||||
|
||||
// 1. Auth check — any authenticated user
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from("users")
|
||||
.select("role, company_id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (!dbUser) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const adminDb = createAdminClient();
|
||||
|
||||
// 2. Get the order from database
|
||||
const { data: order, error } = await adminDb
|
||||
.from("orders")
|
||||
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !order) {
|
||||
return new NextResponse("Order not found", { status: 404 });
|
||||
}
|
||||
|
||||
// 3. Authorization: admin, owner, or company member
|
||||
const isAdmin = dbUser.role === "admin";
|
||||
const isOwner = order.user_id === user.id;
|
||||
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
|
||||
|
||||
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const storageFileName = `ab_${id}.pdf`;
|
||||
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
||||
|
||||
// 4. Try to download from storage if pdf_url is set
|
||||
if (order.pdf_url) {
|
||||
try {
|
||||
const { data: fileData, error: downloadError } = await adminDb
|
||||
.storage
|
||||
.from("invoices")
|
||||
.download(storageFileName);
|
||||
|
||||
if (!downloadError && fileData) {
|
||||
fileBuffer = await fileData.arrayBuffer();
|
||||
}
|
||||
} catch (storageErr) {
|
||||
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. If file not found or pdf_url not set, generate dynamically
|
||||
if (!fileBuffer) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
|
||||
dateStr: formattedDate,
|
||||
customer: order.customer_data,
|
||||
orderData: order.order_data,
|
||||
totalPrice: Number(order.total_price),
|
||||
})
|
||||
);
|
||||
|
||||
// Upload/Upsert to storage bucket
|
||||
const { error: uploadError } = await adminDb.storage
|
||||
.from("invoices")
|
||||
.upload(storageFileName, Buffer.from(buffer), {
|
||||
contentType: "application/pdf",
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
console.error("Failed to upload regenerated PDF:", uploadError);
|
||||
} else {
|
||||
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
|
||||
}
|
||||
|
||||
fileBuffer = buffer;
|
||||
} catch (genErr: any) {
|
||||
console.error("PDF generation failed inside API:", genErr);
|
||||
return new NextResponse("Error generating PDF: " + genErr.message, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Return as PDF response
|
||||
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/pdf");
|
||||
headers.append("Cache-Control", "private, no-cache");
|
||||
if (inline) {
|
||||
headers.append("Content-Disposition", `inline; filename="${downloadFileName}"`);
|
||||
} else {
|
||||
headers.append("Content-Disposition", `attachment; filename="${downloadFileName}"`);
|
||||
}
|
||||
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Download endpoint failed:", err);
|
||||
return new NextResponse(err.message || "Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
297
shop/app/api/orders/checkout/route.ts
Normal file
297
shop/app/api/orders/checkout/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { getProducts, getCategories } from '@/lib/actions/products';
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
|
||||
import { sendMail } from '@/utils/mail';
|
||||
import { createHash } from 'crypto';
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
import { InvoicePDF } from '@/components/invoice-pdf';
|
||||
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
|
||||
import { validateWizardSelections } from '@/lib/actions/validation';
|
||||
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
const rand = Math.floor(10000 + Math.random() * 90000);
|
||||
return `${prefix}-${year}-${rand}`;
|
||||
}
|
||||
|
||||
function hashOrderSnapshot(snapshot: object): string {
|
||||
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { items, customerProfile, endCustomerId, endCustomer } = body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
|
||||
}
|
||||
|
||||
const products = await getProducts();
|
||||
const categories = await getCategories();
|
||||
|
||||
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
|
||||
for (const item of items) {
|
||||
if (item.selections) {
|
||||
validateWizardSelections(item.selections, products, categories);
|
||||
}
|
||||
}
|
||||
|
||||
// Split items into purchase and subscription
|
||||
const purchaseItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
|
||||
);
|
||||
const subscriptionItems = items.filter(
|
||||
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
|
||||
);
|
||||
|
||||
const createdOrders: any[] = [];
|
||||
|
||||
const processOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
|
||||
if (groupItems.length === 0) return;
|
||||
|
||||
// Build consolidated snapshots
|
||||
// For the multi-item support, we combine the order snapshots of each item in the group
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer);
|
||||
|
||||
const orderItemsList: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
item.selections,
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
}
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
items: orderItemsList,
|
||||
total: total,
|
||||
tax_rate: 19, // default
|
||||
tax_amount: Math.round(total * 0.19 * 100) / 100,
|
||||
subtotal: Math.round((total / 1.19) * 100) / 100
|
||||
};
|
||||
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot);
|
||||
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
|
||||
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId || null,
|
||||
total_price: total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
type: type,
|
||||
payment_method: paymentMethod,
|
||||
status: 'pending'
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orderError) throw orderError;
|
||||
|
||||
const fileName = `ab_${order.id}.pdf`;
|
||||
|
||||
// Trigger PDF generation & storage upload
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
})
|
||||
);
|
||||
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
|
||||
|
||||
if (!uploadError) {
|
||||
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
|
||||
order.pdf_url = fileName;
|
||||
}
|
||||
} catch (pdfErr) {
|
||||
console.error('PDF error for order ' + order.id, pdfErr);
|
||||
}
|
||||
|
||||
// Trigger E-Mail notifications
|
||||
if (user.email) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19;
|
||||
const items = orderSnapshot.items ?? [];
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
let totalDetailsText = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
|
||||
}
|
||||
|
||||
let totalDetailsHtml = '';
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`;
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items);
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
|
||||
|
||||
const { data: bufferData, error: downloadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.download(fileName);
|
||||
|
||||
const attachments: any[] = [];
|
||||
if (!downloadError && bufferData) {
|
||||
const buffer = Buffer.from(await bufferData.arrayBuffer());
|
||||
attachments.push({
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Anfragebestätigung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments
|
||||
});
|
||||
} catch (mailErr) {
|
||||
console.error('Mail error for order ' + order.id, mailErr);
|
||||
}
|
||||
}
|
||||
|
||||
createdOrders.push(order);
|
||||
};
|
||||
|
||||
await processOrderGroup(purchaseItems, 'purchase', 'invoice');
|
||||
await processOrderGroup(subscriptionItems, 'subscription', 'sepa');
|
||||
|
||||
return NextResponse.json({ success: true, orders: createdOrders });
|
||||
} catch (error: any) {
|
||||
console.error('Checkout error:', error);
|
||||
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
import { isSetupNeeded } from "@/lib/actions/setup";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
if (await isSetupNeeded()) {
|
||||
redirect("/setup");
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
76
shop/app/auth/verify-link/page.tsx
Normal file
76
shop/app/auth/verify-link/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShieldCheck, Loader2 } from "lucide-react";
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{
|
||||
token_hash?: string;
|
||||
type?: string;
|
||||
next?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function VerifyLinkPage({ searchParams }: PageProps) {
|
||||
const params = use(searchParams);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const tokenHash = params.token_hash || "";
|
||||
const type = params.type || "";
|
||||
const next = params.next || "/";
|
||||
|
||||
const handleSubmit = () => {
|
||||
setIsSubmitting(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh w-full items-center justify-center p-6 md:p-10 overflow-hidden bg-slate-950">
|
||||
{/* Background gradients for rich aesthetics */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#0f172a_1px,transparent_1px),linear-gradient(to_bottom,#0f172a_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-60" />
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[500px] h-[500px] bg-blue-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-indigo-500/10 rounded-full blur-[100px] pointer-events-none" />
|
||||
|
||||
<div className="relative w-full max-w-md z-10">
|
||||
<Card className="border-slate-800/80 bg-slate-900/60 backdrop-blur-xl shadow-2xl transition-all duration-300 hover:border-slate-700/80">
|
||||
<CardHeader className="flex flex-col items-center justify-center text-center space-y-4 pt-8">
|
||||
<div className="p-3.5 bg-blue-500/10 rounded-full text-blue-400 border border-blue-500/20 shadow-[0_0_20px_rgba(59,130,246,0.15)] animate-pulse">
|
||||
<ShieldCheck className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight text-slate-100">
|
||||
Sicherheit prüfen
|
||||
</CardTitle>
|
||||
<p className="text-sm text-slate-400 max-w-xs mx-auto">
|
||||
Bitte bestätigen Sie den Vorgang mit einem Klick auf den Button, um fortzufahren.
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-8">
|
||||
<form action="/auth/confirm" method="GET" onSubmit={handleSubmit} className="w-full">
|
||||
<input type="hidden" name="token_hash" value={tokenHash} />
|
||||
<input type="hidden" name="type" value={type} />
|
||||
<input type="hidden" name="next" value={next} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !tokenHash}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-medium shadow-[0_0_20px_rgba(59,130,246,0.25)] transition-all duration-200 h-11 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Wird weitergeleitet...
|
||||
</>
|
||||
) : (
|
||||
"Bestätigen & Fortfahren"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -27,29 +27,93 @@ export default function Datenschutz() {
|
||||
<div className="space-y-6 text-slate-300">
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">1. Datenschutz auf einen Blick</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-md font-semibold text-slate-200">Allgemeiner Hinweis</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung. Wir weisen darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-semibold text-slate-200">Wer ist verantwortlich für die Datenerfassung auf dieser Website?</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber CASPOS GmbH. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-md font-semibold text-slate-200">Welche Rechte haben Sie bezüglich Ihrer Daten?</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Sie haben jederzeit das Recht unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung, Sperrung oder Löschung dieser Daten zu verlangen. Hierzu sowie zu weiteren Fragen zum Thema Datenschutz können Sie sich jederzeit unter der im Impressum angegebenen Adresse an uns wenden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">2. Erhebung, Verarbeitung und Nutzung personenbezogener Daten</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Personenbezogene Daten werden auf dieser Webseite nur im technisch notwendigen Umfang erhoben und verarbeitet. Die nachfolgenden Erklärungen geben Ihnen einen Überblick darüber, wie wir diesen Schutz gewährleisten und welche Art von Daten zu welchem Zweck erhoben werden.
|
||||
Sie können unsere Webseiten grundsätzlich besuchen, ohne dass wir personenbezogene Daten von Ihnen erheben. Personenbezogene Daten werden nur erhoben, wenn Sie uns diese von sich aus zur Durchführung eines Vertrages, oder im Rahmen der Kontaktaufnahme mitteilen. Diese Daten werden ohne Ihre ausdrückliche Einwilligung jeweils allein zur Vertragsabwicklung und Bearbeitung Ihrer Anfragen genutzt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">2. Verantwortliche Stelle</h2>
|
||||
<h2 className="text-xl font-bold text-white">3. Analyse-Tools und Tools von Drittanbietern</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website ist die CASPOS GmbH (Anschrift siehe Impressum).
|
||||
Beim Besuch unserer Website werden keine Analyse-Tools eingesetzt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">3. Datenerfassung auf unserer Website</h2>
|
||||
<h2 className="text-xl font-bold text-white">4. Cookies</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z. B. um Daten handeln, die Sie in ein Kontakt- oder Bestellformular eingeben. Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das sind vor allem technische Daten (z. B. Internetbrowser, Betriebssystem oder Uhrzeit des Seitenaufrufs).
|
||||
CASPOS GmbH verwendet keine so genannte Cookies.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">4. Ihre Rechte</h2>
|
||||
<h2 className="text-xl font-bold text-white">5. Server-Log-Dateien</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die Berichtigung, Sperrung oder Löschung dieser Daten zu verlangen.
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 text-sm text-slate-400 space-y-1">
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse</li>
|
||||
</ul>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mt-2">
|
||||
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Grundlage für die Datenverarbeitung ist Art. 6 Abs. 1 lit. f DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen gestattet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">6. Weitergabe personenbezogener Daten</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Personenbezogenen Daten werden von uns im Rahmen der Vertragsabwicklung an das mit der Lieferung beauftragte Transportunternehmen weitergegeben, soweit dies zur Lieferung der Ware erforderlich ist.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Bei Vorlage einer richterlichen Anordnung darf CASPOS GmbH Ihre persönlichen Daten an die Strafverfolgungsbehörden weitergeben, ohne Sie davon zu informieren. Ebenso behält sich CASPOS GmbH vor, Ihre persönlichen Daten für gerichtliche Auseinandersetzungen einzusetzen, falls dies notwendig ist.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-xl font-bold text-white">7. Kontaktformular</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Wenn Sie uns per Kontaktformular Anfragen zukommen lassen, werden Ihre Angaben aus dem Anfrageformular inklusive der von Ihnen dort angegebenen Kontaktdaten zwecks Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Die Verarbeitung der in das Kontaktformular eingegebenen Daten erfolgt somit ausschließlich auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können diese Einwilligung jederzeit widerrufen. Dazu reicht eine formlose Mitteilung per E-Mail an uns. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Datenverarbeitungsvorgänge bleibt vom Widerruf unberührt.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Die von Ihnen im Kontaktformular eingegebenen Daten verbleiben bei uns, bis Sie uns zur Löschung auffordern, Ihre Einwilligung zur Speicherung widerrufen oder der Zweck für die Datenspeicherung entfällt (z.B. nach abgeschlossener Bearbeitung Ihrer Anfrage). Zwingende gesetzliche Bestimmungen – insbesondere Aufbewahrungsfristen – bleiben unberührt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -4,48 +4,82 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 33% 98%;
|
||||
--foreground: 220 40% 2%;
|
||||
/* Light Mode: Maximierter Kontrast (Reines Weißer Hintergrund + Tiefschwarzer Text) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 40% 2%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 40% 2%;
|
||||
--primary: 220 90% 56%;
|
||||
--popover-foreground: 224 71% 4%;
|
||||
|
||||
/* Primary: Cyan/Blau lesbarer gemacht */
|
||||
--primary: 199 100% 35%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Muted & Accent: Deutlich dunklerer Text für Barrierefreiheit */
|
||||
--secondary: 220 14% 90%;
|
||||
--secondary-foreground: 220 40% 2%;
|
||||
--muted: 220 14% 90%;
|
||||
--muted-foreground: 220 14% 40%;
|
||||
--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: 220 40% 2%;
|
||||
--destructive: 0 84% 60%;
|
||||
--accent-foreground: 224 71% 4%;
|
||||
|
||||
--destructive: 0 84% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 14% 85%;
|
||||
--input: 220 14% 85%;
|
||||
--ring: 220 90% 56%;
|
||||
--border: 220 14% 75%;
|
||||
/* Grenzen sichtbarer gemacht */
|
||||
--input: 220 14% 75%;
|
||||
--ring: 199 100% 35%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 222.2 47.4% 11.2%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 222.2 47.4% 11.2%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 222.2 47.4% 11.2%;
|
||||
--input: 222.2 47.4% 11.2%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +87,7 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
@@ -60,13 +95,17 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Glassmorphism Kontraste geschärft (höhere Deckkraft) */
|
||||
.glass {
|
||||
@apply bg-white/10 backdrop-blur-md border border-white/20;
|
||||
@apply bg-white/20 backdrop-blur-md border border-white/40;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-black/20 backdrop-blur-md border border-white/10;
|
||||
@apply bg-black/40 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
|
||||
@apply text-blue-500 dark:text-blue-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function Impressum() {
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Impressum
|
||||
</h1>
|
||||
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>
|
||||
@@ -26,22 +26,22 @@ export default function Impressum() {
|
||||
|
||||
<div className="space-y-6 text-slate-300">
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-bold text-white">CASPOS GmbH</h2>
|
||||
<h2 className="text-lg font-bold text-white">CASPOS Computerabrechnungssysteme GmbH</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Musterstraße 123<br />
|
||||
12345 Musterstadt
|
||||
Alte Bundesstraße 16<br />
|
||||
D-76846 Hauenstein
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-bold text-white">Vertreten durch:</h2>
|
||||
<p className="text-sm text-slate-400">Max Mustermann (Geschäftsführer)</p>
|
||||
<h2 className="text-lg font-bold text-white">Vertreten durch die Geschäftsführer:</h2>
|
||||
<p className="text-sm text-slate-400">Helmut Dietz, Tamara Korn, Julian Winter</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-bold text-white">Kontakt:</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Telefon: +49 (0) 123 456789<br />
|
||||
Telefon: +49 (0) 6392 – 8598 440<br />
|
||||
E-Mail: info@caspos.de
|
||||
</p>
|
||||
</section>
|
||||
@@ -49,19 +49,61 @@ export default function Impressum() {
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-bold text-white">Registereintrag:</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Eintragung im Handelsregister.<br />
|
||||
Registergericht: Amtsgericht Musterstadt<br />
|
||||
Registernummer: HRB 12345
|
||||
Handelsregister: HRB 32806, Amtsgericht Zweibrücken
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-bold text-white">Umsatzsteuer-ID:</h2>
|
||||
<h2 className="text-lg font-bold text-white">Umsatzsteuer-ID & Steuernummer:</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Umsatzsteuer-Identifikationsnummer gemäß §27 a Umsatzsteuergesetz:<br />
|
||||
DE 123456789
|
||||
Umsatzsteueridentifikationsnummer: DE 158286059<br />
|
||||
Steuernummer: 35/671/01361
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<hr className="border-slate-900" />
|
||||
|
||||
<section className="space-y-2 text-slate-400 text-xs">
|
||||
<p className="font-semibold text-slate-300">
|
||||
© 2022 CASPOS Computerabrechnungssysteme GmbH – Alle Rechte vorbehalten
|
||||
</p>
|
||||
<p className="leading-relaxed">
|
||||
Sämtliches Material auf dieser Website wie Texte, Bilder, Illustrationen, Audio- und Videoclips, um nur ein paar Beispiele zu nennen (allgemein „das Material“ genannt), sind urheberrechtlich geschützt und dürfen für keinen anderen Zweck verwendet werden. Abdruck, kopieren, verteilen, Fremdnutzung, Posting und andere unerlaubte Nutzung des Materials ist hiermit ausdrücklich verboten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<hr className="border-slate-900" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold text-white">Haftungsausschluss</h2>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-slate-200">1. Inhalt der Onlineanfrage</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Der Autor übernimmt keinerlei Gewähr für die Aktualität, Korrektheit, Vollständigkeit oder Qualität der bereitgestellten Informationen. Haftungsansprüche gegen den Autor, welche sich auf Schäden materieller oder ideeller Art beziehen, die durch die Nutzung oder Nichtnutzung der dargebotenen Informationen bzw. durch die Nutzung fehlerhafter und unvollständiger Informationen verursacht wurden, sind grundsätzlich ausgeschlossen, sofern seitens des Autors kein nachweislich vorsätzliches oder grob fahrlässiges Verschulden vorliegt.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Alle Anfragen sind freibleibend und unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile der Seiten oder die gesamte Anfrage ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-slate-200">2. Verweise und Links</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Bei direkten oder indirekten Verweisen auf fremde Webseiten („Hyperlinks“), die außerhalb des Verantwortungsbereiches des Autors liegen, würde eine Haftungsverpflichtung ausschließlich in dem Fall in Kraft treten, in dem der Autor von den Inhalten Kenntnis hat und es ihm technisch möglich und zumutbar wäre, die Nutzung im Falle rechtswidriger Inhalte zu verhindern. Der Autor erklärt hiermit ausdrücklich, dass zum Zeitpunkt der Linksetzung keine illegalen Inhalte auf den zu verlinkenden Seiten erkennbar waren.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Auf die aktuelle und zukünftige Gestaltung, die Inhalte oder die Urheberschaft der gelinkten/verknüpften Seiten hat der Autor keinerlei Einfluss. Deshalb distanziert er sich hiermit ausdrücklich von allen Inhalten aller gelinkten /verknüpften Seiten, die nach der Linksetzung verändert wurden. Diese Feststellung gilt für alle innerhalb des eigenen Internetanfrage gesetzten Links und Verweise sowie für Fremdeinträge in vom Autor eingerichteten Gästebüchern, Diskussionsforen und Mailinglisten. Für illegale, fehlerhafte oder unvollständige Inhalte und insbesondere für Schäden, die aus der Nutzung oder Nichtnutzung solcherart dargebotener Informationen entstehen, haftet allein der Anbieter der Seite, auf welche verwiesen wurde, nicht derjenige, der über Links auf die jeweilige Veröffentlichung lediglich verweist.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-md font-semibold text-slate-200">4. Rechtswirksamkeit dieses Haftungsausschlusses</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Dieser Haftungsausschluss ist als Teil der Onlineanfrage zu betrachten, von dem aus auf diese Seite verwiesen wurde. Sofern Teile oder einzelne Formulierungen dieses Textes der geltenden Rechtslage nicht, nicht mehr oder nicht vollständig entsprechen sollten, bleiben die übrigen Teile des Dokumentes in ihrem Inhalt und ihrer Gültigkeit davon unberührt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const defaultUrl = process.env.VERCEL_URL
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(defaultUrl),
|
||||
title: "CASPOS-Shop | Die Kasse",
|
||||
description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.",
|
||||
description: "Konfigurieren Sie Ihre maßgeschneiderte Business-Software in Minuten und fragen Sie sie an.",
|
||||
};
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -32,8 +32,9 @@ export default function RootLayout({
|
||||
<body className={`${geistSans.className} antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<InactivityTracker />
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
const [showGdprConfirm, setShowGdprConfirm] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
|
||||
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +39,10 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
zip: c.zip ?? '',
|
||||
city: c.city ?? '',
|
||||
email: c.email ?? '',
|
||||
bank_iban: c.bank_iban ?? '',
|
||||
bank_bic: c.bank_bic ?? '',
|
||||
bank_name: c.bank_name ?? '',
|
||||
bank_owner: c.bank_owner ?? '',
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -126,6 +131,10 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
{ label: 'Straße & Hausnummer', key: 'street', span: true },
|
||||
{ label: 'PLZ', key: 'zip', span: false },
|
||||
{ label: 'Ort', key: 'city', span: false },
|
||||
{ label: 'Kontoinhaber', key: 'bank_owner', span: false },
|
||||
{ label: 'IBAN', key: 'bank_iban', span: false },
|
||||
{ label: 'BIC', key: 'bank_bic', span: false },
|
||||
{ label: 'Bankname', key: 'bank_name', span: false },
|
||||
] as const).map(({ label, key, span }) => (
|
||||
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
|
||||
<Label className="text-slate-300 text-sm">{label}</Label>
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function NewCustomerPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', email: '',
|
||||
bank_iban: '', bank_bic: '', bank_name: '', bank_owner: '',
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -62,6 +63,10 @@ export default function NewCustomerPage() {
|
||||
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' },
|
||||
{ label: 'PLZ', key: 'zip', span: false, placeholder: '12345' },
|
||||
{ label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' },
|
||||
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
|
||||
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
|
||||
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
|
||||
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
|
||||
] as const).map(({ label, key, span, placeholder }) => (
|
||||
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
|
||||
<Label className="text-slate-300 text-sm">{label}</Label>
|
||||
|
||||
@@ -10,13 +10,17 @@ 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',
|
||||
}
|
||||
|
||||
export default async function MyOrdersPage() {
|
||||
@@ -25,11 +29,22 @@ export default async function MyOrdersPage() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/auth/login')
|
||||
|
||||
const { data: orders, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
// Get user's company_id and fetch all company orders
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
let query = supabase.from('orders').select('*')
|
||||
|
||||
if (dbUser?.company_id) {
|
||||
query = query.eq('company_id', dbUser.company_id)
|
||||
} else {
|
||||
query = query.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -44,10 +59,10 @@ export default async function MyOrdersPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||
Meine Bestellungen
|
||||
Meine Anfragen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Alle Ihre Bestellungen auf einen Blick – inkl. aktuellem Status.
|
||||
Alle Ihre Anfragen auf einen Blick – inkl. aktuellem Status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,16 +70,16 @@ export default async function MyOrdersPage() {
|
||||
{/* 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 Bestellungen: {error.message}
|
||||
Fehler beim Laden der Anfragen: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Bestellungen */}
|
||||
{/* 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 Bestellungen aufgegeben.</p>
|
||||
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||
<Link href="/order">
|
||||
<Button>Jetzt konfigurieren</Button>
|
||||
</Link>
|
||||
@@ -72,7 +87,7 @@ export default async function MyOrdersPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bestellliste */}
|
||||
{/* Anfragenliste */}
|
||||
<div className="space-y-4">
|
||||
{(orders ?? []).map((order) => {
|
||||
const o = order as Order
|
||||
@@ -83,7 +98,7 @@ export default async function MyOrdersPage() {
|
||||
{/* 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">Bestellnummer</p>
|
||||
<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">
|
||||
@@ -132,15 +147,22 @@ export default async function MyOrdersPage() {
|
||||
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={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3 h-3 mr-1" /> Angebotsbestätigung ansehen
|
||||
<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={o.pdf_url} download>
|
||||
<a href={`/api/orders/${o.id}/download`} download>
|
||||
<Download className="w-3 h-3 mr-1" /> PDF
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -3,10 +3,16 @@ import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||
import { OrderWizard } from '@/components/order-wizard'
|
||||
import { getCompanies } from '@/lib/actions/companies'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default async function OrderPage() {
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}
|
||||
|
||||
export default async function OrderPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
|
||||
<div className="container mx-auto py-10">
|
||||
@@ -20,28 +26,70 @@ export default async function OrderPage() {
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
|
||||
<OrderDataWrapper />
|
||||
<OrderDataWrapper orderId={params.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function OrderDataWrapper() {
|
||||
async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/login?next=/order')
|
||||
redirect(`/auth/login?next=/order${orderId ? `?id=${orderId}` : ''}`)
|
||||
}
|
||||
|
||||
// Check user role
|
||||
// Check user role and company
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('role')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = userData?.role === 'admin'
|
||||
const hasCompany = !!userData?.company_id
|
||||
|
||||
if (!isAdmin && !hasCompany) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-12 p-6 bg-red-500/10 border border-red-500/20 rounded-2xl text-center space-y-4">
|
||||
<h2 className="text-xl font-bold text-red-400">Kein Unternehmen zugewiesen</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Sie müssen einem Unternehmen zugewiesen sein, um Anfragen erstellen zu können.
|
||||
Bitte wenden Sie sich an den Administrator.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let initialOrder = null
|
||||
if (orderId) {
|
||||
const adminClient = createAdminClient()
|
||||
const { data: orderData, error } = await adminClient
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single()
|
||||
|
||||
if (error || !orderData) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
const isAdmin = userData?.role === 'admin'
|
||||
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) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
initialOrder = orderData
|
||||
}
|
||||
|
||||
const products = await getProducts().catch(() => [])
|
||||
const categories = await getCategories().catch(() => [])
|
||||
|
||||
@@ -75,5 +123,21 @@ async function OrderDataWrapper() {
|
||||
console.error('Fehler beim Laden des Profils:', e)
|
||||
}
|
||||
|
||||
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} />
|
||||
// Fetch companies list for admin
|
||||
let companies: any[] = []
|
||||
if (userData?.role === 'admin') {
|
||||
companies = await getCompanies().catch(() => [])
|
||||
}
|
||||
|
||||
return (
|
||||
<OrderWizard
|
||||
products={products}
|
||||
categories={categories}
|
||||
initialProfile={profile}
|
||||
initialEndCustomers={endCustomers}
|
||||
initialOrder={initialOrder}
|
||||
isAdmin={userData?.role === 'admin'}
|
||||
companies={companies}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,23 +22,59 @@ export default async function OrderSuccessPage({
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/auth/login')
|
||||
|
||||
// Bestellung laden und prüfen ob sie dem eingeloggten User gehört
|
||||
// 1. Hole Benutzerrolle und Company
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
// 2. Hole Bestellung
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !order) redirect('/')
|
||||
|
||||
// Berechtigung prüfen
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const isOwner = order.user_id === user.id
|
||||
const isCompanyMember = dbUser?.company_id && order.company_id === dbUser.company_id
|
||||
|
||||
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
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')
|
||||
const monthlyItems = items.filter((item) => item.billing_interval === 'monthly')
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum, item) => 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, item) => sum + (item.item_total || 0), 0)
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -50,50 +86,57 @@ export default async function OrderSuccessPage({
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Bestellung eingegangen!
|
||||
Anfrage eingegangen!
|
||||
</h1>
|
||||
<p className="text-slate-400 text-lg">
|
||||
Ihre Bestellung wurde erfolgreich gespeichert.
|
||||
Ihre Anfrage wurde erfolgreich gespeichert.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 bg-white/5 border border-white/10 rounded-full px-5 py-2 font-mono text-lg font-bold text-primary">
|
||||
#{o.order_number}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bestell-Details */}
|
||||
{/* Anfrage-Details */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white">Bestellübersicht</CardTitle>
|
||||
<CardTitle className="text-white">Anfrageübersicht</CardTitle>
|
||||
<Badge
|
||||
className={
|
||||
o.status === 'active'
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: o.status === 'cancelled'
|
||||
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
? 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
}
|
||||
>
|
||||
{statusLabel[o.status] ?? o.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Produkte */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
{/* Produkte gruppiert nach Kasse */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedItems).map(([deviceName, devItems]) => (
|
||||
<div key={deviceName} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
|
||||
<div className="bg-white/5 px-2 py-1 rounded text-xs text-primary font-bold">
|
||||
Kasse: {deviceName}
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
{devItems.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1 pl-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -103,12 +146,49 @@ export default async function OrderSuccessPage({
|
||||
<Separator className="bg-white/10" />
|
||||
|
||||
{/* Gesamtbetrag */}
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="text-slate-300">Gesamtbetrag</span>
|
||||
<span className="text-gradient">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
|
||||
</span>
|
||||
</div>
|
||||
{oneTimeNet > 0 && (
|
||||
<div className="space-y-1">
|
||||
{monthlyNet > 0 && <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag</p>}
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. {taxRate}% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto Gesamtbetrag:</span>
|
||||
<span className={monthlyNet > 0 ? "text-white" : "text-gradient"}>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oneTimeNet > 0 && monthlyNet > 0 && (
|
||||
<Separator className="bg-white/5 my-2" />
|
||||
)}
|
||||
|
||||
{monthlyNet > 0 && (
|
||||
<div className="space-y-1">
|
||||
{oneTimeNet > 0 && <p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag</p>}
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / Monat</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. {taxRate}% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / Monat</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto Gesamtbetrag:</span>
|
||||
<span className="text-gradient">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / Monat
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{o.order_data?.price_multiplier !== null && o.order_data?.price_multiplier !== undefined && (
|
||||
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-3 rounded-lg border border-green-500/20 space-y-1">
|
||||
@@ -117,16 +197,16 @@ export default async function OrderSuccessPage({
|
||||
{o.order_data.price_multiplier === 0
|
||||
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
|
||||
: o.order_data.price_multiplier === 0.15
|
||||
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.30
|
||||
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.45
|
||||
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.60
|
||||
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.90
|
||||
? "Neukauf (>= 50 Monate): 10% Rabatt"
|
||||
: `Gebühr: ${o.order_data.price_multiplier * 100}%`}
|
||||
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.30
|
||||
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.45
|
||||
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.60
|
||||
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||
: o.order_data.price_multiplier === 0.90
|
||||
? "Neukauf (>= 50 Monate): 10% Rabatt"
|
||||
: `Gebühr: ${o.order_data.price_multiplier * 100}%`}
|
||||
</p>
|
||||
{o.order_data.last_license_date && (
|
||||
<p className="text-xs font-normal text-slate-400 flex items-center gap-1">
|
||||
@@ -144,18 +224,26 @@ export default async function OrderSuccessPage({
|
||||
<p className="font-semibold text-white">{o.customer_data?.company_name}</p>
|
||||
<p>{o.customer_data?.first_name} {o.customer_data?.last_name}</p>
|
||||
<p>{o.customer_data?.address}, {o.customer_data?.zip_code} {o.customer_data?.city}</p>
|
||||
{o.customer_data?.bank_iban && (
|
||||
<div className="text-xs text-slate-500 pt-2 border-t border-white/5 mt-2 space-y-0.5">
|
||||
<p><span className="font-semibold text-slate-400">Kontoinhaber:</span> {o.customer_data.bank_owner}</p>
|
||||
<p><span className="font-semibold text-slate-400">IBAN:</span> {o.customer_data.bank_iban}</p>
|
||||
{o.customer_data.bank_bic && <p><span className="font-semibold text-slate-400">BIC:</span> {o.customer_data.bank_bic}</p>}
|
||||
{o.customer_data.bank_name && <p><span className="font-semibold text-slate-400">Bank:</span> {o.customer_data.bank_name}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF-Download */}
|
||||
{o.pdf_url && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
||||
<a href={o.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Angebotsbestätigung ansehen
|
||||
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-4 h-4 mr-2" /> Anfragebestätigung ansehen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<a href={o.pdf_url} download>
|
||||
<a href={`/api/orders/${o.id}/download`} download>
|
||||
<Download className="w-4 h-4 mr-2" /> Herunterladen
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { HomeClient } from '@/components/HomeClient'
|
||||
import { isSetupNeeded } from '@/lib/actions/setup'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Home() {
|
||||
if (await isSetupNeeded()) {
|
||||
redirect('/setup')
|
||||
}
|
||||
|
||||
let user = null
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DeployButton } from "@/components/deploy-button";
|
||||
import { EnvVarWarning } from "@/components/env-var-warning";
|
||||
import { AuthButton } from "@/components/auth-button";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { hasEnvVars } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
@@ -47,7 +46,6 @@ export default function ProtectedLayout({
|
||||
Supabase
|
||||
</a>
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
15
shop/app/setup/page.tsx
Normal file
15
shop/app/setup/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { isSetupNeeded } from '@/lib/actions/setup'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { SetupWizard } from '@/components/SetupWizard'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SetupPage() {
|
||||
const needed = await isSetupNeeded()
|
||||
|
||||
if (!needed) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
return <SetupWizard />
|
||||
}
|
||||
@@ -11,8 +11,9 @@ interface HeaderWrapperProps {
|
||||
export function HeaderWrapper({ navbar, children }: HeaderWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
const isAdmin = pathname?.startsWith("/admin");
|
||||
const isSetup = pathname === "/setup";
|
||||
|
||||
if (isAdmin) {
|
||||
if (isAdmin || isSetup) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import { type User } from '@supabase/supabase-js'
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ShoppingCart,
|
||||
Utensils,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Layers,
|
||||
LifeBuoy,
|
||||
Users,
|
||||
ClipboardList,
|
||||
Zap,
|
||||
@@ -68,86 +69,42 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
|
||||
{/* Background Decorative Animated Blobs */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 0.95, 1],
|
||||
rotate: [0, 90, 180, 270, 360],
|
||||
x: [0, 40, -30, 0],
|
||||
y: [0, -20, 50, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 0.9, 1.1, 1],
|
||||
rotate: [0, -90, -180, -270, -360],
|
||||
x: [0, -30, 40, 0],
|
||||
y: [0, 50, -30, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 25,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
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-Order-Portal
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<motion.h1
|
||||
<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"
|
||||
>
|
||||
POS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-400 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
|
||||
<motion.p
|
||||
variants={itemVariants}
|
||||
className="mx-auto max-w-[750px] text-slate-400 text-base sm:text-lg md:text-xl font-normal leading-relaxed"
|
||||
>
|
||||
Erwerben und verwalten Sie digitale Lizenzen für CASPOS Kassensysteme.
|
||||
Sofortige Aktivierung und automatische Bereitstellung für Ihre Kunden.
|
||||
Erwerben und verwalten Sie Lizenzen für CASPOS Kassensysteme.
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Hero CTAs */}
|
||||
<motion.div
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
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">
|
||||
Lizenz bestellen <ArrowRight className="ml-2 h-4 w-4" />
|
||||
<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>
|
||||
<Link href="https://caspos.de/kontakt/" target="_blank" rel="noopener noreferrer" className="w-full sm:w-auto">
|
||||
@@ -163,19 +120,17 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
variants={itemVariants}
|
||||
className="w-full max-w-3xl pt-12"
|
||||
>
|
||||
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
|
||||
{/* Card border glow decoration */}
|
||||
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
Mein Dashboard Schnellzugriff
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link href="/my-customers" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
@@ -192,9 +147,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
|
||||
<Link href="/my-orders" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
@@ -219,75 +174,66 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
{/* 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" /> System-Module
|
||||
</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">
|
||||
Verfügbare CASPOS-Kassen
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-white">
|
||||
Workspace-Zentrale
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
|
||||
Wählen Sie die passende Kasse für Ihren Kunden aus.
|
||||
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Card 1 */}
|
||||
<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"
|
||||
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Handel & ERP</h3>
|
||||
<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">
|
||||
Erweiterungen für Handelskassen, Bestandsführung und vollwertige Warenwirtschaftssysteme (ERP).
|
||||
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div></a>
|
||||
|
||||
{/* Card 2 */}
|
||||
<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"
|
||||
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<Utensils className="h-6 w-6" />
|
||||
<BookOpen className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Gastronomie</h3>
|
||||
<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">
|
||||
Lizenzen für mobiles Bestellen (Orderman), digitale Küchen-Monitore und interaktive Abhol-Boards.
|
||||
Ihr Wissens-Portal für die CASPOS Produktfamilie.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div></a>
|
||||
|
||||
{/* Card 3 */}
|
||||
<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"
|
||||
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<Layers className="h-6 w-6" />
|
||||
<LifeBuoy className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Hybride Systeme</h3>
|
||||
<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">
|
||||
Das Beste aus beiden Welten. Nahtlose Kombination von Handel- und Gastronomie-Funktionalitäten.
|
||||
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -296,18 +242,15 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
|
||||
<div className="container max-w-5xl mx-auto px-4">
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
|
||||
<Activity className="w-3 h-3" /> Ablauf
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center text-white">
|
||||
Wie funktioniert die Bereitstellung?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="relative">
|
||||
{/* Connection line for Desktop */}
|
||||
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
|
||||
|
||||
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] border-t border-dashed border-slate-800 z-0" />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 relative z-10">
|
||||
{/* Step 1 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
@@ -346,46 +289,6 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Core values / Features list */}
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50">
|
||||
<div className="container max-w-5xl mx-auto px-4">
|
||||
<div className="grid gap-8 md:grid-cols-2 items-center">
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<ShieldCheck className="w-3.5 h-3.5" /> Sicherheit & Stabilität
|
||||
</div>
|
||||
<h3 className="text-3xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
Verlässliche Plattform für Lizenz-Aktivierungen
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm sm:text-base leading-relaxed">
|
||||
Unsere Kassenlösungen sind GoBD-konform und offline-fähig. Über den Lizenz-Management-Server werden alle Lizenzen und Module verschlüsselt übertragen und sind innerhalb weniger Millisekunden auf der Kasse aktiv.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<h5 className="font-bold text-sm text-slate-200">GoBD Konformität</h5>
|
||||
<p className="text-xs text-slate-400">Konform nach allen aktuellen steuerrechtlichen Anforderungen.</p>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<h5 className="font-bold text-sm text-slate-200">Same-Day-Bearbeitung</h5>
|
||||
<p className="text-xs text-slate-400">Bearbeitung und Erstellung Ihrer Lizenzen noch am selben Werktag.</p>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<h5 className="font-bold text-sm text-slate-200">Verschlüsselte Keys</h5>
|
||||
<p className="text-xs text-slate-400">Sichere Lizenz-Authentifizierung direkt auf der Kasse.</p>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl border border-slate-900 bg-slate-950/70 space-y-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-500" />
|
||||
<h5 className="font-bold text-sm text-slate-200">Partner Support</h5>
|
||||
<p className="text-xs text-slate-400">Schnelle Hilfe und kompetente Ansprechpartner für Händler.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
||||
|
||||
@@ -98,7 +98,7 @@ 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="/">
|
||||
<span className="font-bold text-xl tracking-tighter text-white">CASPOS Shop</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 +110,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<nav className="hidden md:flex gap-6 items-center">
|
||||
<Link
|
||||
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||
href="https://wiki.caspos.de"
|
||||
href="https://support.teamzone.softengine.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
405
shop/components/SetupWizard.tsx
Normal file
405
shop/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
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 { 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'
|
||||
|
||||
export function SetupWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
// Step 2 Form: Admin account
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
// Step 3 Form: SMTP Config
|
||||
const [smtpForm, setSmtpForm] = useState({
|
||||
host: '',
|
||||
port: '587',
|
||||
secure: false,
|
||||
user: '',
|
||||
pass: '',
|
||||
})
|
||||
|
||||
// Validation checks for Admin form
|
||||
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 handleFinishSetup = async () => {
|
||||
if (!isAdminFormValid || !isSmtpFormValid) return
|
||||
setLoading(true)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
// 1. Submit details via server action
|
||||
const res = await completeSetup(
|
||||
{
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
companyName: adminForm.companyName,
|
||||
firstName: adminForm.firstName,
|
||||
lastName: adminForm.lastName,
|
||||
},
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.success) {
|
||||
setErrorMsg(res.error || 'Fehler beim Setup.')
|
||||
setLoading(false)
|
||||
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('/')
|
||||
router.refresh()
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-8 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<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'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{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">
|
||||
<Sparkles className="w-8 h-8 animate-pulse" />
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/5 text-sm text-slate-400 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Komplett self-hosted & sicher</span>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
className="w-full py-6 text-base font-bold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 shadow-lg shadow-blue-500/15 group"
|
||||
>
|
||||
Setup starten
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="w-6 h-6 text-blue-400" />
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Vorname *</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={adminForm.firstName}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, firstName: e.target.value })}
|
||||
placeholder="Vorname"
|
||||
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">Nachname *</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={adminForm.lastName}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, lastName: e.target.value })}
|
||||
placeholder="Nachname"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
type="email"
|
||||
value={adminForm.email}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, email: e.target.value })}
|
||||
placeholder="admin@firma.de"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Passwort * (min. 6 Zeichen)</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
type="password"
|
||||
value={adminForm.password}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, password: e.target.value })}
|
||||
placeholder="••••••"
|
||||
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">Passwort bestätigen *</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
type="password"
|
||||
value={adminForm.confirmPassword}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, confirmPassword: e.target.value })}
|
||||
placeholder="••••••"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!isAdminFormValid}
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Input
|
||||
value={smtpForm.host}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
||||
placeholder="smtp.domain.de"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
|
||||
<Input
|
||||
value={smtpForm.port}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
||||
placeholder="587"
|
||||
className="bg-white/5 border-white/10 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Benutzername *</Label>
|
||||
<Input
|
||||
value={smtpForm.user}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
||||
placeholder="mail@domain.de"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Passwort</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={smtpForm.pass}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, pass: e.target.value })}
|
||||
placeholder="••••••••••••"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(2)}
|
||||
disabled={loading}
|
||||
className="text-white hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinishSetup}
|
||||
disabled={!isSmtpFormValid || 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 ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Setup wird abgeschlossen...
|
||||
</>
|
||||
) : (
|
||||
'Einrichtung abschließen'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
shop/components/admin/admin-nav-link.tsx
Normal file
28
shop/components/admin/admin-nav-link.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface AdminNavLinkProps {
|
||||
href: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminNavLink({ href, children }: AdminNavLinkProps) {
|
||||
const pathname = usePathname()
|
||||
const isActive = pathname === href
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-primary text-black font-semibold'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -43,6 +43,12 @@ const categorySchema = z.object({
|
||||
icon: z.string().default('Package'),
|
||||
sort_order: z.coerce.number().int().min(0).default(0),
|
||||
is_required: z.boolean().default(true),
|
||||
allow_multiselect: z.boolean().default(false),
|
||||
free_items_limit: z.coerce.number().int().min(0).default(0),
|
||||
preselect: z.boolean().default(true),
|
||||
show_in_kauf: z.boolean().default(true),
|
||||
show_in_abo: z.boolean().default(true),
|
||||
resets_others: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type CategoryFormValues = z.infer<typeof categorySchema>
|
||||
@@ -65,15 +71,21 @@ export function CategoryDialog({
|
||||
icon: category?.icon || 'Package',
|
||||
sort_order: category?.sort_order ?? 0,
|
||||
is_required: category?.is_required ?? true,
|
||||
allow_multiselect: category?.allow_multiselect ?? false,
|
||||
free_items_limit: category?.free_items_limit ?? 0,
|
||||
preselect: category?.preselect ?? true,
|
||||
show_in_kauf: category?.show_in_kauf ?? true,
|
||||
show_in_abo: category?.show_in_abo ?? true,
|
||||
resets_others: category?.resets_others ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: CategoryFormValues) {
|
||||
try {
|
||||
if (category) {
|
||||
await updateCategory(category.id, values)
|
||||
await updateCategory(category.id, values as any)
|
||||
} else {
|
||||
await createCategory(values)
|
||||
await createCategory(values as any)
|
||||
}
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
@@ -184,7 +196,7 @@ export function CategoryDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_required"
|
||||
render={({ field }) => (
|
||||
@@ -207,6 +219,152 @@ export function CategoryDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_multiselect"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (checked) {
|
||||
form.setValue('preselect', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Mehrfachauswahl erlauben
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Erlaubt die Auswahl mehrerer Produkte/Module innerhalb dieser Kategorie.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!form.watch('allow_multiselect') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="preselect"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Vorauswahl aktivieren
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wenn aktiv, wird beim Öffnen des Wizards automatisch das erste Produkt dieser Kategorie ausgewählt.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resets_others"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
Anderen Kategorien bei Auswahl zurücksetzen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wenn aktiv, setzt die Auswahl eines Artikels dieser Kategorie alle anderen Kategorien zurück.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
In Kauf anzeigen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Im Kauf-Zweig des Wizards anzeigen.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-lg border border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-semibold cursor-pointer">
|
||||
In Abo anzeigen
|
||||
</FormLabel>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Im Abo-Zweig des Wizards anzeigen.
|
||||
</p>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch('allow_multiselect') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="free_items_limit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Inklusive Freie Artikel Limit</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="z.B. 2"
|
||||
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Bis zu dieser Anzahl sind gewählte Artikel in dieser Kategorie kostenlos. Alle darüber hinausgehenden werden voll berechnet (günstigste Artikel zuerst frei).
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="pt-4">
|
||||
|
||||
@@ -80,11 +80,18 @@ export function CategoryList({ initialCategories }: { initialCategories: Categor
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.is_required ? (
|
||||
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{category.is_required ? (
|
||||
<Badge className="bg-green-500/20 text-green-600 dark:text-green-400 border border-green-500/30 whitespace-nowrap">Pflicht</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-200 dark:border-white/20 text-slate-500 dark:text-slate-400 whitespace-nowrap">Optional</Badge>
|
||||
)}
|
||||
{category.allow_multiselect && (
|
||||
<Badge className="bg-purple-500/20 text-purple-600 dark:text-purple-400 border border-purple-500/30 whitespace-nowrap">
|
||||
Multi ({category.free_items_limit} frei)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -61,6 +61,7 @@ const productSchema = z.object({
|
||||
requirements: z.array(z.string()).default([]),
|
||||
exclusions: z.array(z.string()).default([]),
|
||||
modules: z.array(moduleSchema).default([]),
|
||||
allow_update_discount: z.boolean().default(true),
|
||||
})
|
||||
|
||||
type ProductFormValues = z.infer<typeof productSchema>
|
||||
@@ -77,8 +78,32 @@ export function CreateProductDialog({
|
||||
products?: Product[]
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const router = useRouter()
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
let isValid = false
|
||||
if (step === 1) {
|
||||
isValid = await form.trigger(['name', 'category_id', 'base_price'])
|
||||
} else if (step === 2) {
|
||||
isValid = await form.trigger('billing_interval')
|
||||
} else if (step === 3) {
|
||||
isValid = await form.trigger(['show_in_kauf', 'show_in_abo'])
|
||||
} else if (step === 4) {
|
||||
isValid = await form.trigger(['requirements', 'exclusions'])
|
||||
}
|
||||
if (isValid || step === 5) {
|
||||
setStep(prev => Math.min(prev + 1, 5))
|
||||
}
|
||||
}
|
||||
|
||||
const otherProducts = products.filter(p => p.id !== product?.id)
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
@@ -93,6 +118,7 @@ export function CreateProductDialog({
|
||||
show_in_abo: product ? (product.show_in_abo ?? true) : true,
|
||||
requirements: product?.requirements || [],
|
||||
exclusions: product?.exclusions || [],
|
||||
allow_update_discount: product ? (product.allow_update_discount ?? true) : true,
|
||||
modules: product?.modules?.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -134,7 +160,7 @@ export function CreateProductDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button className="bg-primary hover:bg-primary/90">
|
||||
@@ -142,7 +168,7 @@ export function CreateProductDialog({
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
|
||||
@@ -155,142 +181,209 @@ export function CreateProductDialog({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-hidden flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto pr-4 space-y-6 py-4 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Step Indicator */}
|
||||
{!product && (
|
||||
<div className="flex justify-between items-center mb-6 border-b border-slate-100 dark:border-white/5 pb-4">
|
||||
{[
|
||||
{ nr: 1, label: 'Allgemein' },
|
||||
{ nr: 2, label: 'Abrechnung' },
|
||||
{ nr: 3, label: 'Sichtbarkeit' },
|
||||
{ nr: 4, label: 'Abhängigkeiten' },
|
||||
{ nr: 5, label: 'Module' }
|
||||
].map(s => (
|
||||
<div key={s.nr} className="flex flex-col items-center gap-1 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
|
||||
step === s.nr
|
||||
? 'bg-primary text-white'
|
||||
: step > s.nr
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
{s.nr}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(product || step === 1) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 2) && (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 3) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch('billing_interval') === 'one_time' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_update_discount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Update-Rabatt erlauben</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">
|
||||
Kunden erhalten für dieses Produkt den selektiven Update-Rabatt.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 4) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Product Requirements */}
|
||||
<FormField
|
||||
@@ -384,33 +477,17 @@ export function CreateProductDialog({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="bg-slate-200 dark:bg-white/10" />
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 5) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<PlusCircle className="w-5 h-5 text-primary" />
|
||||
Module
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => {
|
||||
@@ -423,79 +500,90 @@ export function CreateProductDialog({
|
||||
.filter((_, idx) => idx !== index)
|
||||
|
||||
return (
|
||||
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Cloud Storage" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Aufpreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div key={field.id} className="p-4 border border-slate-200 dark:border-white/10 rounded-xl bg-slate-50 dark:bg-white/5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Zusatzlizenz" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Preis (€/Monat)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20 mt-6"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
|
||||
{/* Requirements Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: reqField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{otherModules.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: mReqField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Erfordert Module</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (reqField.value || []).includes(other.id)
|
||||
const checked = (mReqField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`req-${index}-${other.id}`}
|
||||
id={`mod-req-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = reqField.value || []
|
||||
const currentVal = mReqField.value || []
|
||||
if (checkedState) {
|
||||
reqField.onChange([...currentVal, other.id])
|
||||
mReqField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
reqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mReqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`req-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-req-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -503,44 +591,38 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Exclusions Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: exclField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: mExclField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Schließt Module aus</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (exclField.value || []).includes(other.id)
|
||||
const checked = (mExclField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`excl-${index}-${other.id}`}
|
||||
id={`mod-excl-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = exclField.value || []
|
||||
const currentVal = mExclField.value || []
|
||||
if (checkedState) {
|
||||
exclField.onChange([...currentVal, other.id])
|
||||
mExclField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
exclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mExclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`excl-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-excl-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -548,12 +630,12 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
@@ -591,18 +673,73 @@ export function CreateProductDialog({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-center py-6 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-lg text-slate-500 dark:text-slate-400 text-sm">
|
||||
Noch keine Module hinzugefügt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10 flex justify-between items-center sm:justify-between">
|
||||
{product ? (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep(prev => prev - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step < 5 ? (
|
||||
<Button type="button" onClick={nextStep} className="bg-primary hover:bg-primary/90 text-black font-medium">
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { updateOrderStatus } from "@/lib/actions/orders";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
|
||||
interface OrdersTableProps {
|
||||
initialOrders: any[];
|
||||
@@ -23,6 +24,7 @@ const statusLabel: Record<string, string> = {
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
@@ -30,6 +32,7 @@ const statusClass: Record<string, string> = {
|
||||
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
|
||||
};
|
||||
|
||||
export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
@@ -42,7 +45,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
setOrders(initialOrders);
|
||||
}, [initialOrders]);
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled') => {
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
|
||||
setUpdatingId(orderId);
|
||||
try {
|
||||
await updateOrderStatus(orderId, newStatus);
|
||||
@@ -125,6 +128,14 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
>
|
||||
Storniert
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "rejected" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("rejected")}
|
||||
className="text-xs border-rose-500/20 hover:bg-rose-500/10 text-rose-400"
|
||||
>
|
||||
Abgelehnt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,22 +227,23 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{order.pdf_url ? (
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||
<a href={order.pdf_url} target="_blank" rel="noopener noreferrer">
|
||||
<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={order.pdf_url} download>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">Keine Bestätigung</span>
|
||||
)}
|
||||
<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
|
||||
</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/${order.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
@@ -211,40 +211,42 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isGroupedByCategory ? (
|
||||
groupedProducts.map((group) => (
|
||||
<Fragment key={group.category?.id || 'no-category'}>
|
||||
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
|
||||
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
|
||||
{group.category ? group.category.name : 'Keine Kategorie'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{group.products.map((product) => renderProductRow(product))}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
filteredProducts.map((product) => renderProductRow(product))
|
||||
)}
|
||||
{filteredProducts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
||||
Keine Produkte gefunden. Passen Sie Ihre Filter an.
|
||||
</TableCell>
|
||||
<div className="overflow-auto max-h-[600px] border border-slate-200 dark:border-white/10 rounded-lg scrollbar-thin">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-white dark:bg-slate-900/95 backdrop-blur-sm z-10">
|
||||
<TableRow className="border-slate-200 dark:border-white/10 hover:bg-slate-50 dark:hover:bg-white/5">
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Name</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Basispreis</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Module</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white font-bold">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-700 dark:text-white font-bold">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isGroupedByCategory ? (
|
||||
groupedProducts.map((group) => (
|
||||
<Fragment key={group.category?.id || 'no-category'}>
|
||||
<TableRow className="bg-slate-100 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/40 border-b border-slate-200 dark:border-white/10">
|
||||
<TableCell colSpan={5} className="text-primary font-bold py-2.5 text-xs uppercase tracking-wider">
|
||||
{group.category ? group.category.name : 'Keine Kategorie'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{group.products.map((product) => renderProductRow(product))}
|
||||
</Fragment>
|
||||
))
|
||||
) : (
|
||||
filteredProducts.map((product) => renderProductRow(product))
|
||||
)}
|
||||
{filteredProducts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-10 text-slate-500 dark:text-slate-400">
|
||||
Keine Produkte gefunden. Passen Sie Ihre Filter an.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -23,12 +23,14 @@ const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
@@ -75,67 +77,61 @@ export function AdminRecentOrders() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4 glass-dark border-white/5">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7 bg-[#020617]/85 p-4 rounded-xl">
|
||||
<div className="rounded-xl border border-white/5 bg-[#020617]/85 overflow-hidden backdrop-blur-xl col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Übersicht (letzte 6 Monate)</CardTitle>
|
||||
<CardTitle className="text-white">Übersicht (letzte 6 Monate)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<RevenueChart data={chartData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="col-span-3 glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle>Neue Bestellungen</CardTitle>
|
||||
<div className="flex flex-col gap-4 bg-[#020617]/85 p-4 rounded-xl border border-white/5 col-span-3">
|
||||
<div className="flex flex-row items-center justify-between pb-3">
|
||||
<h3 className="font-semibold text-lg text-white">Neue Bestellungen</h3>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Aktualisiert: {lastChecked.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm italic text-center py-8">Noch keine Bestellungen</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map(order => {
|
||||
const isNew = newOrderIds.has(order.id)
|
||||
const customerName =
|
||||
order.customer_data?.company_name ||
|
||||
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
|
||||
'Unbekannt'
|
||||
|
||||
return (
|
||||
<Link key={order.id} href="/admin/orders" className="block">
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${
|
||||
isNew
|
||||
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-white/5'
|
||||
orders.slice(0, 3).map(order => {
|
||||
const isNew = newOrderIds.has(order.id)
|
||||
const customerName =
|
||||
order.customer_data?.company_name ||
|
||||
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
|
||||
'Unbekannt'
|
||||
return (
|
||||
<Link key={order.id} href="/admin/orders" className="block">
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${isNew
|
||||
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
|
||||
: 'bg-[#020617]/20 hover:bg-[#020617]/30 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
|
||||
{customerName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{customerName}</p>
|
||||
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<p className="font-medium text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||
</p>
|
||||
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>
|
||||
{statusLabel[order.status] ?? order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
|
||||
{customerName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{customerName}</p>
|
||||
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<p className="font-medium text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||
</p>
|
||||
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>{statusLabel[order.status] ?? order.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Plus, Building2 } from 'lucide-react'
|
||||
|
||||
const userSchema = z.object({
|
||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||
role: z.enum(['admin', 'partner']),
|
||||
role: z.enum(['admin', 'partner', 'verwaltung']),
|
||||
company_id: z.string().optional().or(z.literal('')),
|
||||
first_name: z.string().optional().or(z.literal('')),
|
||||
last_name: z.string().optional().or(z.literal('')),
|
||||
@@ -96,12 +96,17 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
first_name: values.first_name || undefined,
|
||||
last_name: values.last_name || undefined,
|
||||
}
|
||||
await createUser(payload)
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
const res = await createUser(payload)
|
||||
if (res.success) {
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(res.error || 'Fehler beim Erstellen des Benutzers.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error)
|
||||
alert('Ein unerwarteter Fehler ist aufgetreten.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +190,7 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
>
|
||||
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
|
||||
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
|
||||
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -197,24 +203,24 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
render={({ field }) => {
|
||||
const selectedCompany = localCompanies.find((c) => c.id === field.value)
|
||||
return (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormItem className="w-full">
|
||||
<FormLabel className="text-slate-900 dark:text-white">Firma</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-slate-500 shrink-0" />
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="relative w-full" ref={dropdownRef}>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDropdownOpen(!dropdownOpen)
|
||||
setSearch('')
|
||||
}}
|
||||
className="w-full text-left text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 max-w-full truncate"
|
||||
className="w-full text-left text-sm bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-md px-3 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 truncate"
|
||||
>
|
||||
{selectedCompany ? selectedCompany.name : 'Keine Firma'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute z-50 mt-1 left-0 w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-slate-200 dark:border-white/10">
|
||||
@@ -227,7 +233,7 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<div className="max-h-40 overflow-y-auto flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -235,9 +241,8 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
setDropdownOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||
!field.value ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${!field.value ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
Keine Firma
|
||||
</button>
|
||||
@@ -252,9 +257,8 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
setDropdownOpen(false)
|
||||
setSearch('')
|
||||
}}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||
field.value === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors truncate ${field.value === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
@@ -265,53 +269,55 @@ export function UserDialog({ companies }: UserDialogProps) {
|
||||
</div>
|
||||
|
||||
{showNewCompanyInput ? (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="flex flex-col sm:flex-row gap-2 mt-1 w-full">
|
||||
<Input
|
||||
placeholder="Name der neuen Firma"
|
||||
value={newCompanyName}
|
||||
onChange={(e) => setNewCompanyName(e.target.value)}
|
||||
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs h-8"
|
||||
className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs h-8 w-full"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const trimmed = newCompanyName.trim()
|
||||
if (trimmed) {
|
||||
setIsCreatingCompany(true)
|
||||
try {
|
||||
const created = await createCompany({ name: trimmed })
|
||||
if (created) {
|
||||
setLocalCompanies((prev) => [...prev, created])
|
||||
field.onChange(created.id)
|
||||
setShowNewCompanyInput(false)
|
||||
setNewCompanyName('')
|
||||
router.refresh()
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const trimmed = newCompanyName.trim()
|
||||
if (trimmed) {
|
||||
setIsCreatingCompany(true)
|
||||
try {
|
||||
const created = await createCompany({ name: trimmed })
|
||||
if (created) {
|
||||
setLocalCompanies((prev) => [...prev, created])
|
||||
field.onChange(created.id)
|
||||
setShowNewCompanyInput(false)
|
||||
setNewCompanyName('')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create company:', err)
|
||||
} finally {
|
||||
setIsCreatingCompany(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create company:', err)
|
||||
} finally {
|
||||
setIsCreatingCompany(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isCreatingCompany}
|
||||
className="bg-primary text-xs h-8"
|
||||
>
|
||||
{isCreatingCompany ? 'Erstellt...' : 'Speichern'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewCompanyInput(false)
|
||||
setNewCompanyName('')
|
||||
}}
|
||||
className="text-xs h-8 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
}}
|
||||
disabled={isCreatingCompany}
|
||||
className="bg-primary text-xs h-8"
|
||||
>
|
||||
{isCreatingCompany ? 'Erstellt...' : 'Speichern'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewCompanyInput(false)
|
||||
setNewCompanyName('')
|
||||
}}
|
||||
className="text-xs h-8 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -17,17 +17,19 @@ import { deleteUser, resetPassword, updateUserRole, updateUserProfile } from '@/
|
||||
import { assignCompanyToUser } from '@/lib/actions/companies'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
type Role = 'admin' | 'partner' | 'gesperrt'
|
||||
type Role = 'admin' | 'partner' | 'verwaltung' | 'gesperrt'
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
admin: 'Admin',
|
||||
partner: 'Partner',
|
||||
verwaltung: 'Verwaltung',
|
||||
gesperrt: 'Gesperrt',
|
||||
}
|
||||
|
||||
const ROLE_BADGE_CLASSES: Record<Role, string> = {
|
||||
admin: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
|
||||
partner: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
verwaltung: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
|
||||
gesperrt: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
}
|
||||
|
||||
@@ -61,17 +63,22 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
if (confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
|
||||
setLoadingId(id)
|
||||
try {
|
||||
await deleteUser(id)
|
||||
router.refresh()
|
||||
const res = await deleteUser(id)
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
} else {
|
||||
console.error('Fehler beim Löschen des Benutzers:', res.error)
|
||||
if (res.error?.includes('Belege')) {
|
||||
if (confirm('Löschen nicht möglich! Auf diesen User existieren bereits Belege. Möchten Sie diesen Benutzer stattdessen sperren?')) {
|
||||
await handleRoleChange(id, 'gesperrt')
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Löschen: ' + res.error)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Löschen des Benutzers:', e)
|
||||
if (e.message?.includes('Belege')) {
|
||||
if (confirm('Löschen nicht möglich! Auf diesen User existieren bereits Belege. Möchten Sie diesen Benutzer stattdessen sperren?')) {
|
||||
await handleRoleChange(id, 'gesperrt')
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Löschen: ' + (e.message || JSON.stringify(e)))
|
||||
}
|
||||
alert('Fehler beim Löschen: ' + (e.message || JSON.stringify(e)))
|
||||
} finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
@@ -81,10 +88,15 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
const handleRoleChange = async (id: string, newRole: Role) => {
|
||||
setLoadingId(id + '-role')
|
||||
try {
|
||||
await updateUserRole(id, newRole)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const res = await updateUserRole(id, newRole)
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(res.error || 'Fehler beim Ändern der Rolle.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Ändern der Rolle:', e)
|
||||
alert('Fehler beim Ändern der Rolle: ' + (e.message || JSON.stringify(e)))
|
||||
} finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
@@ -94,10 +106,15 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
const newRole = currentRole === 'gesperrt' ? 'partner' : 'gesperrt'
|
||||
setLoadingId(id + '-lock')
|
||||
try {
|
||||
await updateUserRole(id, newRole)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const res = await updateUserRole(id, newRole)
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(res.error || 'Fehler beim Sperren/Entsperren.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Sperren/Entsperren:', e)
|
||||
alert('Fehler beim Sperren/Entsperren: ' + (e.message || JSON.stringify(e)))
|
||||
} finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
@@ -135,12 +152,12 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
|
||||
const displayUsers = groupByCompany
|
||||
? [...filteredUsers].sort((a, b) => {
|
||||
const nameA = a.company_name || 'Keine Firma'
|
||||
const nameB = b.company_name || 'Keine Firma'
|
||||
if (!a.company_name && b.company_name) return 1
|
||||
if (a.company_name && !b.company_name) return -1
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
const nameA = a.company_name || 'Keine Firma'
|
||||
const nameB = b.company_name || 'Keine Firma'
|
||||
if (!a.company_name && b.company_name) return 1
|
||||
if (a.company_name && !b.company_name) return -1
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
: filteredUsers
|
||||
|
||||
console.log('COMPANIES IN USERLIST:', companies)
|
||||
@@ -205,7 +222,7 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayUsers.map((user: any) => {
|
||||
const role: Role = (['admin', 'partner', 'gesperrt'].includes(user.role) ? user.role : 'partner') as Role
|
||||
const role: Role = (['admin', 'partner', 'verwaltung', 'gesperrt'].includes(user.role) ? user.role : 'partner') as Role
|
||||
const isLoadingRole = loadingId === user.id + '-role'
|
||||
const isLoadingCompany = loadingId === user.id + '-company'
|
||||
const isLoadingDelete = loadingId === user.id
|
||||
@@ -239,10 +256,15 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
const val = e.target.value.trim()
|
||||
if (val !== (user.first_name || '')) {
|
||||
try {
|
||||
await updateUserProfile(user.id, { first_name: val || null, last_name: user.last_name })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
const res = await updateUserProfile(user.id, { first_name: val || null, last_name: user.last_name })
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(res.error || 'Fehler beim Aktualisieren des Vornamens.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update first name:', err)
|
||||
alert('Fehler beim Aktualisieren des Vornamens: ' + (err.message || JSON.stringify(err)))
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -261,10 +283,15 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
const val = e.target.value.trim()
|
||||
if (val !== (user.last_name || '')) {
|
||||
try {
|
||||
await updateUserProfile(user.id, { first_name: user.first_name, last_name: val || null })
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
const res = await updateUserProfile(user.id, { first_name: user.first_name, last_name: val || null })
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
} else {
|
||||
alert(res.error || 'Fehler beim Aktualisieren des Nachnamens.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update last name:', err)
|
||||
alert('Fehler beim Aktualisieren des Nachnamens: ' + (err.message || JSON.stringify(err)))
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -286,17 +313,19 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
|
||||
className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${
|
||||
role === 'admin'
|
||||
className={`text-xs font-semibold rounded px-3 py-1.5 cursor-pointer focus:outline-none focus:ring-2 transition-colors bg-white dark:bg-slate-950/80 ${role === 'admin'
|
||||
? 'text-purple-600 dark:text-purple-300 border border-purple-500/40 focus:ring-purple-500/50'
|
||||
: role === 'gesperrt'
|
||||
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50'
|
||||
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50'
|
||||
}`}
|
||||
: role === 'verwaltung'
|
||||
? 'text-emerald-600 dark:text-emerald-300 border border-emerald-500/40 focus:ring-emerald-500/50'
|
||||
: role === 'gesperrt'
|
||||
? 'text-red-600 dark:text-red-300 border border-red-500/40 focus:ring-red-500/50'
|
||||
: 'text-blue-600 dark:text-blue-300 border border-blue-500/40 focus:ring-blue-500/50'
|
||||
}`}
|
||||
style={{ appearance: 'auto' }}
|
||||
>
|
||||
<option value="admin" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Admin</option>
|
||||
<option value="partner" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Partner</option>
|
||||
<option value="verwaltung" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Verwaltung</option>
|
||||
<option value="gesperrt" className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">Gesperrt</option>
|
||||
</select>
|
||||
)}
|
||||
@@ -322,42 +351,40 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
)}
|
||||
</div>
|
||||
{companyDropdownOpen === user.id && (
|
||||
<div className="absolute z-50 mt-1 left-0 w-56 bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-lg shadow-xl overflow-hidden">
|
||||
<div className="p-2 border-b border-slate-200 dark:border-white/10">
|
||||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-md shadow-lg p-1 flex flex-col max-h-60 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-slate-100 dark:border-white/5 mb-1 sticky top-0 bg-white dark:bg-slate-900 z-10">
|
||||
<Search className="h-3.5 w-3.5 text-slate-500 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Firma suchen..."
|
||||
value={companySearch}
|
||||
onChange={(e) => setCompanySearch(e.target.value)}
|
||||
className="w-full text-xs bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded px-2 py-1.5 text-slate-900 dark:text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
autoFocus
|
||||
className="w-full text-xs bg-transparent border-0 p-0 focus:outline-none focus:ring-0 text-slate-900 dark:text-slate-200 placeholder-slate-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { handleCompanyChange(user.id, ''); setCompanyDropdownOpen(null); setCompanySearch(''); }}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||
!user.company_id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
Keine Firma
|
||||
</button>
|
||||
{companies
|
||||
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => { handleCompanyChange(user.id, c.id); setCompanyDropdownOpen(null); setCompanySearch(''); }}
|
||||
className={`w-full text-left text-xs px-3 py-2 hover:bg-slate-100 dark:hover:bg-white/10 transition-colors ${
|
||||
user.company_id === c.id ? 'text-primary font-semibold' : 'text-slate-700 dark:text-slate-300'
|
||||
|
||||
<button
|
||||
onClick={() => handleCompanyChange(user.id, '')}
|
||||
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-slate-100 dark:hover:bg-white/5 text-red-500 font-medium"
|
||||
>
|
||||
Keine Firma (Zuweisung aufheben)
|
||||
</button>
|
||||
|
||||
{companies
|
||||
.filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase()))
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => handleCompanyChange(user.id, c.id)}
|
||||
className={`w-full text-left px-2 py-1.5 text-xs rounded hover:bg-slate-100 dark:hover:bg-white/5 transition-colors ${user.company_id === c.id
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -367,35 +394,35 @@ export function UserList({ initialUsers, companies }: UserListProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
|
||||
title="Passwort zurücksetzen"
|
||||
onClick={() => handleResetPassword(user.email)}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={role === 'gesperrt' ? 'h-8 w-8 text-green-500 hover:text-green-400 hover:bg-green-500/10' : 'h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10'}
|
||||
title={role === 'gesperrt' ? 'Entsperren' : 'Sperren'}
|
||||
onClick={() => handleToggleLock(user.id, role)}
|
||||
disabled={loadingId === user.id + '-lock'}
|
||||
>
|
||||
{loadingId === user.id + '-lock' ? <Loader2 className="h-4 w-4 animate-spin" /> : role === 'gesperrt' ? <Unlock className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Löschen"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={isLoadingDelete}
|
||||
>
|
||||
{isLoadingDelete ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10"
|
||||
title="Passwort zurücksetzen"
|
||||
onClick={() => handleResetPassword(user.email)}
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={role === 'gesperrt' ? 'h-8 w-8 text-green-500 hover:text-green-400 hover:bg-green-500/10' : 'h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10'}
|
||||
title={role === 'gesperrt' ? 'Entsperren' : 'Sperren'}
|
||||
onClick={() => handleToggleLock(user.id, role)}
|
||||
disabled={loadingId === user.id + '-lock'}
|
||||
>
|
||||
{loadingId === user.id + '-lock' ? <Loader2 className="h-4 w-4 animate-spin" /> : role === 'gesperrt' ? <Unlock className="h-4 w-4" /> : <Ban className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Löschen"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={isLoadingDelete}
|
||||
>
|
||||
{isLoadingDelete ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -30,8 +30,12 @@ export function ForgotPasswordForm({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await resetPassword(email);
|
||||
setSuccess(true);
|
||||
const res = await resetPassword(email);
|
||||
if (res.success) {
|
||||
setSuccess(true);
|
||||
} else {
|
||||
setError(res.error || "Ein Fehler ist aufgetreten.");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
|
||||
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
|
||||
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 50,
|
||||
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 2,
|
||||
},
|
||||
table: {
|
||||
|
||||
width: 'auto',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
@@ -63,21 +61,16 @@ const styles = StyleSheet.create({
|
||||
padding: 8,
|
||||
textAlign: 'right',
|
||||
},
|
||||
tableCell: {
|
||||
margin: 'auto',
|
||||
marginTop: 5,
|
||||
fontSize: 10,
|
||||
},
|
||||
total: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 20,
|
||||
borderTop: 1,
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#000',
|
||||
paddingTop: 10,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 14,
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
footer: {
|
||||
@@ -88,18 +81,59 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
fontSize: 8,
|
||||
borderTop: 1,
|
||||
borderTopWidth: 1,
|
||||
borderColor: '#eee',
|
||||
paddingTop: 10,
|
||||
},
|
||||
sepaBox: {
|
||||
marginTop: 15,
|
||||
padding: 10,
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
}
|
||||
});
|
||||
|
||||
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
const items = orderSnapshot?.items ?? [];
|
||||
const subtotal = orderSnapshot?.subtotal ?? 0;
|
||||
const taxAmount = orderSnapshot?.tax_amount ?? 0;
|
||||
const taxRate = orderSnapshot?.tax_rate ?? 19;
|
||||
const total = orderSnapshot?.total ?? 0;
|
||||
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
|
||||
|
||||
let oneTimeNet = 0;
|
||||
let monthlyNet = 0;
|
||||
|
||||
items.forEach((item: any) => {
|
||||
if (item.billing_interval === 'monthly') {
|
||||
monthlyNet += item.base_price || 0;
|
||||
} else {
|
||||
oneTimeNet += item.base_price || 0;
|
||||
}
|
||||
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
// Module sind monatliche wiederkehrende Beträge (Abo/Miete)
|
||||
monthlyNet += price;
|
||||
});
|
||||
});
|
||||
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
|
||||
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
|
||||
|
||||
const formattedPrice = (val: number) =>
|
||||
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
|
||||
const groupedItems: { [key: string]: any[] } = {};
|
||||
items.forEach((item: any) => {
|
||||
const devName = item.device_name || 'Kasse 1';
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = [];
|
||||
}
|
||||
groupedItems[devName].push(item);
|
||||
});
|
||||
|
||||
return (
|
||||
<Document>
|
||||
@@ -107,20 +141,22 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
|
||||
<Text>Software Solutions</Text>
|
||||
<Text>Die Kasse</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
<Text>CASPOS GmbH</Text>
|
||||
<Text>Musterstraße 1</Text>
|
||||
<Text>12345 Musterstadt</Text>
|
||||
<Text>CASPOS Computerabrechnungssysteme GmbH</Text>
|
||||
<Text>Alte Bundesstraße 16</Text>
|
||||
<Text>76846 Hauenstein</Text>
|
||||
<Text>Deutschland</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title}>Angebotsbestätigung</Text>
|
||||
<Text style={styles.title}>
|
||||
Anfragebestätigung: {order.order_number || order.id}
|
||||
</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.label}>Besteller / Kunde</Text>
|
||||
<Text style={styles.label}>Kunde</Text>
|
||||
<Text>{customer.company_name}</Text>
|
||||
<Text>{customer.first_name} {customer.last_name}</Text>
|
||||
<Text>{customer.address}</Text>
|
||||
@@ -131,97 +167,111 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
|
||||
<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>
|
||||
|
||||
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
|
||||
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
|
||||
<Text style={styles.label}>Update-Konditionen</Text>
|
||||
<Text style={{ fontWeight: 'bold' }}>
|
||||
{orderSnapshot.price_multiplier === 0
|
||||
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
|
||||
: orderSnapshot.price_multiplier === 0.15
|
||||
? "Update (13-14 Monate): 15% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.30
|
||||
? "Update (15-25 Monate): 30% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.45
|
||||
? "Update (26-37 Monate): 45% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.60
|
||||
? "Update (38-49 Monate): 60% vom Listenpreis"
|
||||
: orderSnapshot.price_multiplier === 0.90
|
||||
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
|
||||
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
|
||||
</Text>
|
||||
{orderSnapshot.last_license_date && (
|
||||
<Text style={{ marginTop: 2, color: '#666' }}>
|
||||
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.table}>
|
||||
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
|
||||
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
|
||||
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
|
||||
</View>
|
||||
|
||||
{items.map((item: any, idx: number) => (
|
||||
<View key={idx}>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={styles.tableCol}>
|
||||
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
|
||||
</View>
|
||||
<View style={styles.tableColPrice}>
|
||||
<Text>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ marginTop: 10, marginBottom: 15 }}>
|
||||
{Object.entries(groupedItems).map(([deviceName, devItems], groupIdx) => (
|
||||
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
|
||||
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
|
||||
Kasse: {deviceName}
|
||||
</Text>
|
||||
</View>
|
||||
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
const hasQty = qty > 1;
|
||||
return (
|
||||
<View key={mIdx} style={styles.tableRow}>
|
||||
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.tableColPrice}>
|
||||
<Text style={{ color: '#666' }}>
|
||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
|
||||
</Text>
|
||||
</View>
|
||||
{devItems.map((item: any, idx: number) => (
|
||||
<View key={idx} style={{ marginBottom: 6 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#334155' }}>
|
||||
{item.product_name} ({item.category_name})
|
||||
</Text>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#0f172a' }}>
|
||||
{formattedPrice(item.base_price)} {item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{item.selected_modules?.map((mod: any, mIdx: number) => {
|
||||
const qty = mod.quantity || 1;
|
||||
const price = mod.total_price ?? (mod.price * qty);
|
||||
return (
|
||||
<View key={mIdx} style={{ flexDirection: 'row', justifyContent: 'space-between', paddingLeft: 12, marginBottom: 1 }}>
|
||||
<Text style={{ fontSize: 8, color: '#64748b' }}>
|
||||
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#64748b' }}>
|
||||
+{formattedPrice(price)} mtl.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.total}>
|
||||
<View style={{ width: '50%' }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(subtotal)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(taxAmount)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
|
||||
<Text style={styles.totalLabel}>Gesamtbetrag (brutto):</Text>
|
||||
<Text style={styles.totalLabel}>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(total)}</Text>
|
||||
</View>
|
||||
<View style={{ width: '75%' }}>
|
||||
{oneTimeNet > 0 && (
|
||||
<View style={{ marginBottom: 6 }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Einmalige Beträge:</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{formattedPrice(oneTimeNet)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{formattedPrice(oneTimeTax)}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt einmalig (brutto):</Text>
|
||||
<Text>{formattedPrice(oneTimeGross)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{monthlyNet > 0 && (
|
||||
<View style={{ marginTop: 4, paddingTop: 4, borderTopWidth: oneTimeNet > 0 ? 1 : 0, borderTopColor: '#e2e8f0' }}>
|
||||
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Monatlich wiederkehrende Beträge:</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>Netto-Zwischensumme:</Text>
|
||||
<Text>{formattedPrice(monthlyNet)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
|
||||
<Text>zzgl. {taxRate}% USt:</Text>
|
||||
<Text>{formattedPrice(monthlyTax)} / mtl.</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<Text>Gesamt monatlich (brutto):</Text>
|
||||
<Text>{formattedPrice(monthlyGross)} / mtl.</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isSubscription ? (
|
||||
<View style={styles.sepaBox}>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 4 }}>SEPA-Lastschriftmandat</Text>
|
||||
<Text style={{ fontSize: 8, color: '#555' }}>
|
||||
Ich ermächtige die CASPOS GmbH, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von der CASPOS GmbH auf mein Konto gezogenen Lastschriften einzulösen. Das Mandat wird erst mit der finalen Aktivierung der Lizenzen durch den CASPOS-Support wirksam.
|
||||
</Text>
|
||||
{customer.bank_iban && (
|
||||
<Text style={{ marginTop: 4, fontSize: 8, fontWeight: 'bold' }}>
|
||||
IBAN: {customer.bank_iban.replace(/(.{4})/g, '$1 ')} · Inhaber: {customer.bank_owner || 'Kunde'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 20 }}>
|
||||
<Text style={{ fontSize: 9, color: '#555', fontStyle: 'italic' }}>
|
||||
Dies ist eine unverbindliche Bestätigung Ihrer Anfrage. Ein verbindliches Angebot wird Ihnen in Kürze von unserem Vertriebsteam zugesendet.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text>Vielen Dank für Ihre Bestellung!</Text>
|
||||
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
|
||||
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
|
||||
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
|
||||
@@ -102,22 +102,22 @@ export function LoginForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errorParam === "gesperrt" && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||
</p>
|
||||
)}
|
||||
{messageParam === "concurrent" && (
|
||||
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{errorParam === "gesperrt" && (
|
||||
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
||||
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
||||
</p>
|
||||
)}
|
||||
{messageParam === "concurrent" && (
|
||||
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
||||
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,8 +43,12 @@ export function UpdatePasswordForm({
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePassword(password);
|
||||
router.push("/");
|
||||
const res = await updatePassword(password);
|
||||
if (res.success) {
|
||||
router.push("/");
|
||||
} else {
|
||||
setError(res.error || "Ein Fehler ist aufgetreten");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten");
|
||||
} finally {
|
||||
|
||||
480
shop/components/wizard/license-lookup-panel.tsx
Normal file
480
shop/components/wizard/license-lookup-panel.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useTransition, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
KeyRound,
|
||||
Search,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Building2,
|
||||
User,
|
||||
CalendarDays,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
Hash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Fingerprint,
|
||||
} from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { lookupLicenseFromLicServer } from '@/lib/actions/licserver-config'
|
||||
|
||||
// ─── MOCK DATA (until real LicServer API is wired up) ───────────────────────
|
||||
const MOCK_LICENSES: Record<string, LicenseInfo> = {
|
||||
'CAS-2023-PRO-00123': {
|
||||
licenseKey: 'CAS-2023-PRO-00123',
|
||||
status: 'active',
|
||||
product: 'CAS genesisWorld Professional',
|
||||
edition: 'Professional',
|
||||
version: '14.1.2',
|
||||
seats: 10,
|
||||
customer: 'Mustermann GmbH',
|
||||
contact: 'Max Mustermann',
|
||||
issuedAt: '2023-04-01',
|
||||
expiresAt: '2026-03-31',
|
||||
maintenanceUntil: '2025-03-31',
|
||||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client'],
|
||||
},
|
||||
'CAS-2020-STD-00456': {
|
||||
licenseKey: 'CAS-2020-STD-00456',
|
||||
status: 'expired',
|
||||
product: 'CAS genesisWorld Standard',
|
||||
edition: 'Standard',
|
||||
version: '12.0.0',
|
||||
seats: 5,
|
||||
customer: 'Beispiel AG',
|
||||
contact: 'Erika Muster',
|
||||
issuedAt: '2020-01-15',
|
||||
expiresAt: '2023-01-14',
|
||||
maintenanceUntil: '2022-01-14',
|
||||
modules: ['CRM'],
|
||||
},
|
||||
'CAS-2024-ENT-00789': {
|
||||
licenseKey: 'CAS-2024-ENT-00789',
|
||||
status: 'active',
|
||||
product: 'CAS genesisWorld Enterprise',
|
||||
edition: 'Enterprise',
|
||||
version: '15.0.0',
|
||||
seats: 50,
|
||||
customer: 'Tech Solutions GmbH & Co. KG',
|
||||
contact: 'Julia Schneider',
|
||||
issuedAt: '2024-01-01',
|
||||
expiresAt: '2026-12-31',
|
||||
maintenanceUntil: '2026-12-31',
|
||||
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client', 'AI Assistant', 'Analytics Pro'],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── TYPES ───────────────────────────────────────────────────────────────────
|
||||
export type LicenseStatus = 'active' | 'expired' | 'invalid' | 'suspended'
|
||||
|
||||
export interface LicenseInfo {
|
||||
licenseKey: string
|
||||
status: LicenseStatus
|
||||
product: string
|
||||
edition: string
|
||||
version: string
|
||||
seats: number
|
||||
customer: string
|
||||
contact: string
|
||||
issuedAt: string
|
||||
expiresAt: string
|
||||
maintenanceUntil: string
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
interface LicenseLookupPanelProps {
|
||||
/** Called when a valid license is found – so the wizard can prefill fields */
|
||||
onLicenseResolved?: (info: LicenseInfo) => void
|
||||
}
|
||||
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||||
function statusConfig(status: LicenseStatus) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return {
|
||||
label: 'Aktiv',
|
||||
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
||||
ring: 'border-emerald-500/30',
|
||||
glow: 'shadow-emerald-500/10',
|
||||
}
|
||||
case 'expired':
|
||||
return {
|
||||
label: 'Abgelaufen',
|
||||
icon: <AlertTriangle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
|
||||
ring: 'border-amber-500/30',
|
||||
glow: 'shadow-amber-500/10',
|
||||
}
|
||||
case 'suspended':
|
||||
return {
|
||||
label: 'Gesperrt',
|
||||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||||
ring: 'border-red-500/30',
|
||||
glow: 'shadow-red-500/10',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Ungültig',
|
||||
icon: <XCircle className="w-3.5 h-3.5" />,
|
||||
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
|
||||
ring: 'border-red-500/30',
|
||||
glow: 'shadow-red-500/10',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(dateStr: string) {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
|
||||
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
|
||||
try {
|
||||
return await lookupLicenseFromLicServer(key)
|
||||
} catch (err) {
|
||||
console.error('Lookup failed, falling back to mock details:', err)
|
||||
return MOCK_LICENSES[key.trim().toUpperCase()] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MAIN COMPONENT ───────────────────────────────────────────────────────────
|
||||
export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProps) {
|
||||
const [licenseKey, setLicenseKey] = useState('')
|
||||
const [result, setResult] = useState<LicenseInfo | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [modulesOpen, setModulesOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!licenseKey.trim()) return
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
startTransition(async () => {
|
||||
const info = await lookupLicense(licenseKey)
|
||||
if (info) {
|
||||
setResult(info)
|
||||
onLicenseResolved?.(info)
|
||||
} else {
|
||||
setNotFound(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') handleSearch()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setLicenseKey('')
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const sc = result ? statusConfig(result.status) : null
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl">
|
||||
|
||||
{/* ── Panel Header (toggle) ─────────────────────────────────────────── */}
|
||||
<button
|
||||
id="license-lookup-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
|
||||
<KeyRound className="w-4 h-4 text-violet-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-semibold text-white">Alte Lizenznummer eingeben</p>
|
||||
<p className="text-[11px] text-slate-400">Upgrade / Migration</p>
|
||||
</div>
|
||||
</div>
|
||||
{expanded
|
||||
? <ChevronUp className="w-4 h-4 text-slate-400" />
|
||||
: <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||
</button>
|
||||
|
||||
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="body"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-5 pb-5 space-y-4 border-t border-white/5">
|
||||
|
||||
{/* ── Input + Search Button ──────────────────────────────── */}
|
||||
<div className="pt-4">
|
||||
<p className="text-xs text-slate-400 mb-2 leading-relaxed">
|
||||
Geben Sie Ihre bisherige CAS-Lizenznummer ein,
|
||||
um Lizenzinformationen automatisch abzurufen.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Input */}
|
||||
<div className="relative flex-1">
|
||||
<Fingerprint className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-violet-400 pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="license-lookup-input"
|
||||
value={licenseKey}
|
||||
onChange={e => {
|
||||
setLicenseKey(e.target.value)
|
||||
setResult(null)
|
||||
setNotFound(false)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="z. B. CAS-2023-PRO-00123"
|
||||
className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500
|
||||
focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10"
|
||||
/>
|
||||
{licenseKey && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
|
||||
aria-label="Eingabe löschen"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<button
|
||||
id="license-lookup-search-btn"
|
||||
onClick={handleSearch}
|
||||
disabled={isPending || !licenseKey.trim()}
|
||||
className="flex items-center gap-1.5 px-3.5 h-10 rounded-xl bg-violet-600 hover:bg-violet-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium
|
||||
transition-all shadow-lg shadow-violet-500/20 shrink-0"
|
||||
>
|
||||
{isPending
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Search className="w-4 h-4" />}
|
||||
Prüfen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Demo quick-fill hints */}
|
||||
<p className="text-[10px] text-slate-500 mt-1.5">
|
||||
Demo:
|
||||
<span
|
||||
className="font-mono text-violet-400/80 cursor-pointer hover:text-violet-400 transition-colors"
|
||||
onClick={() => setLicenseKey('CAS-2023-PRO-00123')}
|
||||
>
|
||||
CAS-2023-PRO-00123
|
||||
</span>
|
||||
{' · '}
|
||||
<span
|
||||
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
|
||||
onClick={() => setLicenseKey('CAS-2020-STD-00456')}
|
||||
>
|
||||
CAS-2020-STD-00456
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Animated Result Area ──────────────────────────────── */}
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isPending && (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-7 rounded-lg bg-white/5 animate-pulse" />
|
||||
))}
|
||||
<p className="text-center text-[11px] text-slate-500 animate-pulse">
|
||||
Lizenzserver wird abgefragt…
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Not Found */}
|
||||
{notFound && !isPending && (
|
||||
<motion.div
|
||||
key="notfound"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex items-start gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/30"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">Lizenz nicht gefunden</p>
|
||||
<p className="text-xs text-red-400/70 mt-0.5">
|
||||
Die eingegebene Nummer wurde im Lizenzsystem nicht gefunden.
|
||||
Bitte prüfen Sie die Eingabe.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Result Card */}
|
||||
{result && !isPending && sc && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`rounded-xl border ${sc.ring} bg-slate-900/80 shadow-lg ${sc.glow} overflow-hidden`}
|
||||
>
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-slate-300" />
|
||||
<span className="text-xs font-mono text-slate-300 truncate max-w-[140px]">
|
||||
{result.licenseKey}
|
||||
</span>
|
||||
</div>
|
||||
<Badge className={`text-[10px] px-2 py-0.5 flex items-center gap-1 border ${sc.cls}`}>
|
||||
{sc.icon}
|
||||
{sc.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Info Rows */}
|
||||
<div className="px-4 py-3 space-y-2.5">
|
||||
|
||||
{/* Product */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Package className="w-3.5 h-3.5 text-violet-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Produkt</p>
|
||||
<p className="text-xs font-semibold text-white leading-tight">{result.product}</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{result.edition} · v{result.version} · {result.seats} Arbeitsplätze
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-blue-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Lizenznehmer</p>
|
||||
<p className="text-xs font-semibold text-white leading-tight">{result.customer}</p>
|
||||
<p className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<User className="w-2.5 h-2.5" />
|
||||
{result.contact}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<CalendarDays className="w-2.5 h-2.5" /> Ausgestellt
|
||||
</p>
|
||||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.issuedAt)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<CalendarDays className="w-2.5 h-2.5" /> Läuft ab
|
||||
</p>
|
||||
<p className={`text-xs font-medium mt-0.5 ${result.status === 'expired' ? 'text-amber-400' : 'text-white'}`}>
|
||||
{fmt(result.expiresAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/5 px-2.5 py-2 col-span-2">
|
||||
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
|
||||
<Hash className="w-2.5 h-2.5" /> Wartung bis
|
||||
</p>
|
||||
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.maintenanceUntil)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules (collapsible) */}
|
||||
{result.modules.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setModulesOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between text-[10px] text-slate-400
|
||||
hover:text-white transition-colors pt-1"
|
||||
>
|
||||
<span className="uppercase tracking-wide">
|
||||
Lizenzierte Module ({result.modules.length})
|
||||
</span>
|
||||
{modulesOpen
|
||||
? <ChevronUp className="w-3 h-3" />
|
||||
: <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{modulesOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 pt-2">
|
||||
{result.modules.map(m => (
|
||||
<span
|
||||
key={m}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15
|
||||
border border-violet-500/30 text-violet-300"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom hint */}
|
||||
{result.status === 'active' && (
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-[10px] text-emerald-400/70 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Lizenz erkannt — Daten können für die Bestellung übernommen werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.status === 'expired' && (
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Lizenz abgelaufen — Upgrade empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
shop/components/wizard/progress-stepper.tsx
Normal file
39
shop/components/wizard/progress-stepper.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ProgressStepperProps {
|
||||
step: number
|
||||
basketItemsCount: number
|
||||
}
|
||||
|
||||
export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mb-12">
|
||||
<div className="flex justify-between relative">
|
||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||
step >= s
|
||||
? 'bg-blue-600 text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{step > s ? <Check className="w-5 h-5" /> : s}
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${step >= s ? 'text-blue-400' : 'text-slate-500'} flex items-center gap-1`}>
|
||||
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
|
||||
{s === 3 && basketItemsCount > 0 && (
|
||||
<span className="bg-blue-600 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
|
||||
{basketItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
shop/components/wizard/step-billing.tsx
Normal file
127
shop/components/wizard/step-billing.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CreditCard, Calendar, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface StepBillingProps {
|
||||
selectedBillingInterval: 'one_time' | 'monthly'
|
||||
handleBillingIntervalChange: (val: 'one_time' | 'monthly') => void
|
||||
customerMode: 'select' | 'create'
|
||||
selectedEndCustomerId: string | null
|
||||
lastLicenseDate: string
|
||||
setLastLicenseDate: (date: string) => void
|
||||
prevStep: () => void
|
||||
nextStep: () => void
|
||||
}
|
||||
|
||||
export function StepBilling({
|
||||
selectedBillingInterval,
|
||||
handleBillingIntervalChange,
|
||||
customerMode,
|
||||
selectedEndCustomerId,
|
||||
lastLicenseDate,
|
||||
setLastLicenseDate,
|
||||
prevStep,
|
||||
nextStep,
|
||||
}: StepBillingProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<CreditCard className="w-6 h-6 text-primary" />
|
||||
Abrechnungsmodell wählen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid md:grid-cols-2 gap-6 py-4">
|
||||
{/* Option 1: Kauf */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('one_time')}
|
||||
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||
selectedBillingInterval === 'one_time'
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'one_time' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||
<CreditCard className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Einmaliger Kauf</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren.
|
||||
</p>
|
||||
</div>
|
||||
{selectedBillingInterval === 'one_time' && (
|
||||
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Option 2: Abo */}
|
||||
<div
|
||||
onClick={() => handleBillingIntervalChange('monthly')}
|
||||
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
|
||||
selectedBillingInterval === 'monthly'
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
|
||||
: 'border-white/5 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'monthly' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">Monatliches Abo (Miete)</h3>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit.
|
||||
</p>
|
||||
</div>
|
||||
{selectedBillingInterval === 'monthly' && (
|
||||
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-6 max-w-md animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
Datum der letzten CAS-Lizenz (falls vorhanden)
|
||||
</Label>
|
||||
<Input
|
||||
id="last-license-date"
|
||||
type="date"
|
||||
value={lastLicenseDate}
|
||||
onChange={e => setLastLicenseDate(e.target.value)}
|
||||
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||
<Button variant="ghost" className="text-white" onClick={prevStep}>
|
||||
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
|
||||
</Button>
|
||||
<Button onClick={nextStep}>
|
||||
Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
224
shop/components/wizard/step-customer.tsx
Normal file
224
shop/components/wizard/step-customer.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { User, Building2, UserPlus, Search, Check, Loader2, ChevronRight } from 'lucide-react'
|
||||
import { EndCustomer, Profile } from '@/lib/types'
|
||||
|
||||
interface StepCustomerProps {
|
||||
isAdmin: boolean
|
||||
companies: any[]
|
||||
selectedCompanyId: string | null
|
||||
setSelectedCompanyId: (id: string | null) => void
|
||||
customerMode: 'select' | 'create'
|
||||
setCustomerMode: (mode: 'select' | 'create') => void
|
||||
endCustomers: EndCustomer[]
|
||||
searchTerm: string
|
||||
setSearchTerm: (term: string) => void
|
||||
filteredEndCustomers: EndCustomer[]
|
||||
selectedEndCustomerId: string | null
|
||||
setSelectedEndCustomerId: (id: string | null) => void
|
||||
newCustomerForm: any
|
||||
setNewCustomerForm: React.Dispatch<React.SetStateAction<any>>
|
||||
handleCreateCustomer: () => Promise<void>
|
||||
isCreatingCustomer: boolean
|
||||
nextStep: () => void
|
||||
}
|
||||
|
||||
export function StepCustomer({
|
||||
isAdmin,
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
setSelectedCompanyId,
|
||||
customerMode,
|
||||
setCustomerMode,
|
||||
endCustomers,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filteredEndCustomers,
|
||||
selectedEndCustomerId,
|
||||
setSelectedEndCustomerId,
|
||||
newCustomerForm,
|
||||
setNewCustomerForm,
|
||||
handleCreateCustomer,
|
||||
isCreatingCustomer,
|
||||
nextStep,
|
||||
}: StepCustomerProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<User className="w-6 h-6 text-primary" />
|
||||
Für wen bestellen Sie?
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Wählen Sie einen Endkunden aus oder legen Sie einen neuen an.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Admin Company Selector */}
|
||||
{isAdmin && (
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
|
||||
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
Zugeordnetes Unternehmen (Admin-Option)
|
||||
</Label>
|
||||
<select
|
||||
id="order-company"
|
||||
value={selectedCompanyId || ''}
|
||||
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
|
||||
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
|
||||
>
|
||||
<option value="">Keine Firma zugewiesen</option>
|
||||
{companies.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modus-Toggle */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={customerMode === 'select' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('select')}
|
||||
className={`gap-2 ${customerMode === 'select' ? '' : 'text-white'}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4" /> Bestandskunde wählen
|
||||
</Button>
|
||||
<Button
|
||||
variant={customerMode === 'create' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setCustomerMode('create')}
|
||||
className={`gap-2 ${customerMode === 'create' ? '' : 'text-white'}`}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Modus A: Bestandskunde wählen */}
|
||||
{customerMode === 'select' && (
|
||||
<div className="space-y-4">
|
||||
{endCustomers.filter(c => !c.is_anonymized).length === 0 ? (
|
||||
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
|
||||
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
|
||||
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10 text-white">
|
||||
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Kunden suchen nach Name, Ort, PLZ..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
{filteredEndCustomers.length === 0 ? (
|
||||
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
|
||||
<p className="text-slate-400">Keine passenden Kunden gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
|
||||
{filteredEndCustomers.map(customer => (
|
||||
<label
|
||||
key={customer.id}
|
||||
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${selectedEndCustomerId === customer.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="end_customer"
|
||||
value={customer.id}
|
||||
checked={selectedEndCustomerId === customer.id}
|
||||
onChange={() => setSelectedEndCustomerId(customer.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-white">{customer.company_name}</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
|
||||
{customer.first_name || customer.last_name ? ' · ' : ''}
|
||||
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
{selectedEndCustomerId === customer.id && (
|
||||
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modus B: Neuen Kunden anlegen */}
|
||||
{customerMode === 'create' && (
|
||||
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
{([
|
||||
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
|
||||
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
|
||||
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
|
||||
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
|
||||
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
|
||||
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
|
||||
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
|
||||
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
|
||||
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
|
||||
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
|
||||
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
|
||||
] as const).map(({ label, key, span, placeholder }) => (
|
||||
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
|
||||
<Label className="text-slate-300 text-sm">{label}</Label>
|
||||
<Input
|
||||
value={newCustomerForm[key] || ''}
|
||||
onChange={e => setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button
|
||||
onClick={handleCreateCustomer}
|
||||
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
|
||||
Kunden speichern & auswählen
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-white" onClick={() => setCustomerMode('select')}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
|
||||
<Button
|
||||
onClick={nextStep}
|
||||
disabled={
|
||||
customerMode === 'create' ||
|
||||
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
|
||||
}
|
||||
>
|
||||
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
295
shop/components/wizard/step-software.tsx
Normal file
295
shop/components/wizard/step-software.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShoppingCart, Check, AlertCircle } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||
|
||||
interface StepSoftwareProps {
|
||||
visibleCategories: Category[]
|
||||
products: Product[]
|
||||
selections: Record<string, CategorySelection>
|
||||
selectProduct: (catId: string, prodId: string) => void
|
||||
isProductDisabled: (prod: Product, catId: string) => boolean
|
||||
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
|
||||
toggleModule: (catId: string, modId: string) => void
|
||||
moduleQuantities: Record<string, number>
|
||||
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
|
||||
selectedBillingInterval: 'one_time' | 'monthly'
|
||||
billingLabel: (interval: string) => string
|
||||
billingBadgeClass: (interval: string) => string
|
||||
}
|
||||
|
||||
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||
const LucideIcon = icon ? (Icons as any)[icon] : null
|
||||
if (!LucideIcon) return <Icons.HelpCircle className={className} />
|
||||
return <LucideIcon className={className} />
|
||||
}
|
||||
|
||||
export function StepSoftware({
|
||||
visibleCategories,
|
||||
products,
|
||||
selections,
|
||||
selectProduct,
|
||||
isProductDisabled,
|
||||
isModuleDisabled,
|
||||
toggleModule,
|
||||
moduleQuantities,
|
||||
setModuleQuantities,
|
||||
selectedBillingInterval,
|
||||
billingLabel,
|
||||
billingBadgeClass,
|
||||
}: StepSoftwareProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2 text-white">
|
||||
<ShoppingCart className="w-6 h-6 text-blue-400" />
|
||||
Software wählen
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Wählen Sie pro Kategorie mindestens einen Artikel aus.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
{visibleCategories.map((cat, idx) => {
|
||||
const catProducts = products.filter(p => {
|
||||
if (p.category_id !== cat.id) return false
|
||||
return selectedBillingInterval === 'one_time'
|
||||
? p.show_in_kauf !== false
|
||||
: p.show_in_abo !== false
|
||||
})
|
||||
const sel = selections[cat.id]
|
||||
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
|
||||
|
||||
{/* Category header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
|
||||
{cat.description && (
|
||||
<p className="text-slate-400 text-xs">{cat.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{sel?.productIds && sel.productIds.length > 0 ? (
|
||||
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
|
||||
</Badge>
|
||||
) : sel?.productId ? (
|
||||
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<Check className="w-3 h-3 mr-1" /> Ausgewählt
|
||||
</Badge>
|
||||
) : cat.is_required ? (
|
||||
<Badge variant="destructive" className="ml-auto opacity-80">
|
||||
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
|
||||
Optional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catProducts.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm italic">
|
||||
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
|
||||
</p>
|
||||
) : cat.allow_multiselect ? (
|
||||
<div className="grid gap-3">
|
||||
{catProducts.map(product => {
|
||||
const isChecked = sel?.productIds?.includes(product.id) ?? false
|
||||
const disabled = isProductDisabled(product, cat.id)
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<Label
|
||||
onClick={() => !disabled && selectProduct(cat.id, product.id)}
|
||||
className={`flex flex-col items-start p-4 rounded-xl border-2 transition-all ${disabled
|
||||
? 'border-white/5 bg-white/5 opacity-50 cursor-not-allowed'
|
||||
: isChecked
|
||||
? 'border-primary bg-primary/5 cursor-pointer'
|
||||
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => { }}
|
||||
className="border-white/20 data-[state=checked]:bg-primary"
|
||||
/>
|
||||
<span className="font-bold text-base text-white">{product.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary font-semibold text-sm">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
|
||||
)}
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-xs text-slate-500 mt-1 pl-7">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={sel?.productId ?? ''}
|
||||
onValueChange={id => selectProduct(cat.id, id)}
|
||||
className="grid gap-3"
|
||||
>
|
||||
{catProducts.map(product => {
|
||||
const disabled = isProductDisabled(product, cat.id)
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<RadioGroupItem
|
||||
value={product.id}
|
||||
id={`${cat.id}-${product.id}`}
|
||||
disabled={disabled}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={disabled ? undefined : `${cat.id}-${product.id}`}
|
||||
className={`flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 transition-all ${disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-base text-white">{product.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
|
||||
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-primary font-semibold text-sm">
|
||||
{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(product.base_price)}{' '}
|
||||
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{product.description && (
|
||||
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
|
||||
)}
|
||||
{product.modules && product.modules.length > 0 && (
|
||||
<span className="text-xs text-slate-500 mt-1">
|
||||
{product.modules.length} optionale Module verfügbar
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{/* Modules */}
|
||||
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
|
||||
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
|
||||
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
|
||||
{selectedProduct.modules.map(module => {
|
||||
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
|
||||
const checked = sel?.moduleIds.includes(module.id) ?? false
|
||||
return (
|
||||
<div
|
||||
key={module.id}
|
||||
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={`mod-${cat.id}-${module.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleModule(cat.id, module.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor={`mod-${cat.id}-${module.id}`}
|
||||
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span>{module.name}</span>
|
||||
<span className="text-primary font-bold">
|
||||
+{new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price)}
|
||||
</span>
|
||||
</Label>
|
||||
{module.description && (
|
||||
<p className="text-xs text-slate-400">{module.description}</p>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{module.requirements?.length && !module.requirements.some(
|
||||
reqId => sel?.moduleIds.includes(reqId)
|
||||
)
|
||||
? 'Benötigt weitere Module'
|
||||
: 'Nicht kombinierbar mit aktueller Auswahl'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scalable Quantity */}
|
||||
{checked && module.has_quantity && (
|
||||
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
|
||||
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
|
||||
<Input
|
||||
id={`qty-${module.id}`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={moduleQuantities[module.id] || 1}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, parseInt(e.target.value) || 1)
|
||||
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
|
||||
}}
|
||||
className="w-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
Gesamt: {new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(module.price * (moduleQuantities[module.id] || 1))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
275
shop/components/wizard/step-summary.tsx
Normal file
275
shop/components/wizard/step-summary.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ShieldCheck, Building2, Calendar, Loader2 } from 'lucide-react'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { Category, Product, EndCustomer, Profile } from '@/lib/types'
|
||||
|
||||
interface StepSummaryProps {
|
||||
finalItemsToShow: any[]
|
||||
visibleCategories: Category[]
|
||||
products: Product[]
|
||||
oneTimeTotal: number
|
||||
monthlyTotal: number
|
||||
oneTimeNet: number
|
||||
oneTimeTax: number
|
||||
oneTimeGross: number
|
||||
monthlyNet: number
|
||||
monthlyTax: number
|
||||
monthlyGross: number
|
||||
updatePriceModifier: { label?: string }
|
||||
selectedEndCustomer: EndCustomer | null
|
||||
customerData: Partial<Profile>
|
||||
lastLicenseDate: string
|
||||
handleSubmit: () => Promise<void>
|
||||
isSubmitting: boolean
|
||||
initialOrder: any
|
||||
prevStep: () => void
|
||||
}
|
||||
|
||||
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
|
||||
const LucideIcon = icon ? (Icons as any)[icon] : null
|
||||
if (!LucideIcon) return <Icons.HelpCircle className={className} />
|
||||
return <LucideIcon className={className} />
|
||||
}
|
||||
|
||||
export function StepSummary({
|
||||
finalItemsToShow,
|
||||
visibleCategories,
|
||||
products,
|
||||
oneTimeTotal,
|
||||
monthlyTotal,
|
||||
oneTimeNet,
|
||||
oneTimeTax,
|
||||
oneTimeGross,
|
||||
monthlyNet,
|
||||
monthlyTax,
|
||||
monthlyGross,
|
||||
updatePriceModifier,
|
||||
selectedEndCustomer,
|
||||
customerData,
|
||||
lastLicenseDate,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
initialOrder,
|
||||
prevStep,
|
||||
}: StepSummaryProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
|
||||
<CardHeader>
|
||||
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
|
||||
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl text-white">Anfrage prüfen</CardTitle>
|
||||
<CardDescription className="text-slate-300">
|
||||
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 text-left">
|
||||
<div className="p-4 rounded-lg bg-white/5 space-y-6">
|
||||
{finalItemsToShow.map((item, itemIdx) => (
|
||||
<div key={itemIdx} className="space-y-3 border-b border-white/10 pb-4 last:border-0 last:pb-0">
|
||||
<div className="flex justify-between items-center bg-white/5 p-2 rounded">
|
||||
<span className="text-white font-bold text-sm">Kasse: {item.deviceName}</span>
|
||||
<span className="text-xs text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
|
||||
</div>
|
||||
|
||||
{visibleCategories.map(cat => {
|
||||
const sel = item.selections[cat.id]
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel?.productIds) {
|
||||
sel.productIds.forEach((pId: string) => {
|
||||
const p = products.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel?.productId) {
|
||||
const p = products.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
if (selectedProds.length === 0) return null
|
||||
|
||||
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
|
||||
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="space-y-1 pl-2">
|
||||
<div className="text-slate-400 font-semibold text-xs flex items-center gap-1">
|
||||
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary" />
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const actualPrice = isFree ? 0 : prod.base_price
|
||||
const catTotal =
|
||||
Number(actualPrice) +
|
||||
(sel?.moduleIds?.reduce((acc: number, mId: string) => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
const qty = item.moduleQuantities[mId] || 1
|
||||
return acc + (Number(mod?.price ?? 0) * qty)
|
||||
}, 0) || 0)
|
||||
|
||||
return (
|
||||
<div key={prod.id} className="pl-3">
|
||||
<div className="flex justify-between font-semibold text-sm text-white">
|
||||
<span>
|
||||
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
|
||||
</span>
|
||||
<span>
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
|
||||
</span>
|
||||
</div>
|
||||
{sel?.moduleIds && sel.moduleIds.length > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Module: {sel.moduleIds
|
||||
.map((id: string) => {
|
||||
const mod = prod.modules?.find(m => m.id === id)
|
||||
const qty = item.moduleQuantities[id] || 1
|
||||
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<Separator className="bg-white/10" />
|
||||
<div className="text-sm text-slate-300 space-y-1">
|
||||
{selectedEndCustomer ? (
|
||||
<>
|
||||
<p className="font-semibold text-white flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-primary" />
|
||||
{selectedEndCustomer.company_name}
|
||||
</p>
|
||||
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
|
||||
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
|
||||
{lastLicenseDate && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-white">{customerData.company_name}</p>
|
||||
<p>{customerData.first_name} {customerData.last_name}</p>
|
||||
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-4 border-t border-white/10 pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag:</p>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 pt-2 border-t border-white/10">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag:</p>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-white">
|
||||
<span>Brutto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : oneTimeTotal > 0 ? (
|
||||
<div className="space-y-1 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
|
||||
{updatePriceModifier.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 border-t border-white/10 pt-4">
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 italic">
|
||||
Mit dem Klick auf „Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
|
||||
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
|
||||
Datenschutzerklärung
|
||||
</a>.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button
|
||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
initialOrder ? (
|
||||
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird aktualisiert...</>
|
||||
) : (
|
||||
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird versendet...</>
|
||||
)
|
||||
) : (
|
||||
initialOrder ? 'Anfrage aktualisieren' : 'Anfrage versenden'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||
Noch etwas ändern
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
297
shop/components/wizard/summary-sidebar.tsx
Normal file
297
shop/components/wizard/summary-sidebar.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2, UserPlus, ChevronRight, AlertCircle, Check } from 'lucide-react'
|
||||
import { Category, Product, CategorySelection } from '@/lib/types'
|
||||
|
||||
interface SummarySidebarProps {
|
||||
visibleCategories: Category[]
|
||||
selections: Record<string, CategorySelection>
|
||||
products: Product[]
|
||||
moduleQuantities: Record<string, number>
|
||||
billingLabel: (interval: string) => string
|
||||
oneTimeTotal: number
|
||||
monthlyTotal: number
|
||||
oneTimeNet: number
|
||||
oneTimeTax: number
|
||||
oneTimeGross: number
|
||||
monthlyNet: number
|
||||
monthlyTax: number
|
||||
monthlyGross: number
|
||||
updatePriceModifier: { label?: string }
|
||||
allCategoriesFilled: boolean
|
||||
productValidationErrors: string[]
|
||||
basketItems: any[]
|
||||
editingIdx: number | null
|
||||
editBasketItem: (idx: number) => void
|
||||
deleteBasketItem: (idx: number) => void
|
||||
deviceName: string
|
||||
setDeviceName: (name: string) => void
|
||||
addToBasket: () => void
|
||||
isNextStepDisabled: boolean
|
||||
hasActiveSelection: boolean
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
}
|
||||
|
||||
export function SummarySidebar({
|
||||
visibleCategories,
|
||||
selections,
|
||||
products,
|
||||
moduleQuantities,
|
||||
billingLabel,
|
||||
oneTimeTotal,
|
||||
monthlyTotal,
|
||||
oneTimeNet,
|
||||
oneTimeTax,
|
||||
oneTimeGross,
|
||||
monthlyNet,
|
||||
monthlyTax,
|
||||
monthlyGross,
|
||||
updatePriceModifier,
|
||||
allCategoriesFilled,
|
||||
productValidationErrors,
|
||||
basketItems,
|
||||
editingIdx,
|
||||
editBasketItem,
|
||||
deleteBasketItem,
|
||||
deviceName,
|
||||
setDeviceName,
|
||||
addToBasket,
|
||||
isNextStepDisabled,
|
||||
hasActiveSelection,
|
||||
nextStep,
|
||||
prevStep,
|
||||
}: SummarySidebarProps) {
|
||||
return (
|
||||
<Card className="glass-dark border-primary/20 backdrop-blur-lg bg-slate-950/75 flex flex-col max-h-[80vh]">
|
||||
<CardHeader className="shrink-0">
|
||||
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 flex-1 overflow-y-auto subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
|
||||
{visibleCategories.map(cat => {
|
||||
const sel = selections[cat.id]
|
||||
const selectedProds: Product[] = []
|
||||
if (cat.allow_multiselect && sel?.productIds) {
|
||||
sel.productIds.forEach(pId => {
|
||||
const p = products.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel?.productId) {
|
||||
const p = products.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
if (selectedProds.length === 0) return (
|
||||
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
|
||||
{cat.is_required ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
) : (
|
||||
<span className="w-3 h-3 inline-block" />
|
||||
)}
|
||||
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
|
||||
</div>
|
||||
)
|
||||
|
||||
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
|
||||
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-400 font-medium">{cat.name}</span>
|
||||
</div>
|
||||
{sortedProds.map((prod, idx) => {
|
||||
const isFree = idx < freeLimit
|
||||
const actualPrice = isFree ? 0 : prod.base_price
|
||||
return (
|
||||
<div key={prod.id} className="space-y-0.5 pl-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
|
||||
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{sel.moduleIds.map(mId => {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
const qty = moduleQuantities[mId] || 1
|
||||
return mod ? (
|
||||
<div key={mId} className="flex justify-between text-xs pl-2">
|
||||
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
|
||||
<span className="text-slate-300">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Separator className="bg-white/10" />
|
||||
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmalig:</p>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-white">
|
||||
<span>Gesamt (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 pt-2 border-t border-white/10">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlich:</p>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm font-bold text-white">
|
||||
<span>Gesamt (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : oneTimeTotal > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>Netto-Gesamtbetrag:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>zzgl. 19% MwSt.:</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
|
||||
<span>Gesamtbetrag (brutto):</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updatePriceModifier.label && (
|
||||
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
|
||||
<p className="font-semibold flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
Update-Rabatt aktiv
|
||||
</p>
|
||||
<p>{updatePriceModifier.label}</p>
|
||||
</div>
|
||||
)}
|
||||
{!allCategoriesFilled && (
|
||||
<p className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
|
||||
</p>
|
||||
)}
|
||||
{productValidationErrors.map((err, errIdx) => (
|
||||
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{basketItems.length > 0 && (
|
||||
<div className="pt-4 border-t border-white/10 space-y-2">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Warenkorb ({basketItems.length}):</p>
|
||||
<div className="space-y-1.5">
|
||||
{basketItems.map((item, idx) => (
|
||||
<div key={idx} className={`flex justify-between items-center text-xs bg-white/5 p-2 rounded border transition-all duration-300 ${idx === editingIdx ? 'border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]' : 'border-white/10'}`}>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-medium">{item.deviceName}</span>
|
||||
<span className="text-[10px] text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-7 h-7 hover:bg-white/10 text-slate-400 hover:text-white"
|
||||
onClick={() => editBasketItem(idx)}
|
||||
title="Kasse bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-7 h-7 hover:bg-white/10 text-destructive hover:text-red-400"
|
||||
onClick={() => deleteBasketItem(idx)}
|
||||
title="Kasse löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-3 shrink-0">
|
||||
<div className="w-full space-y-2">
|
||||
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
|
||||
<Input
|
||||
id="device-name"
|
||||
placeholder="z.B. Hauptkasse, Theke"
|
||||
value={deviceName}
|
||||
onChange={e => setDeviceName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white text-xs h-9 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-primary/30 text-white hover:bg-primary/10 gap-2 h-10 text-sm"
|
||||
onClick={addToBasket}
|
||||
disabled={isNextStepDisabled}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {editingIdx !== null ? '💾 Änderungen an Kasse speichern' : (hasActiveSelection ? 'Kasse speichern' : 'Kasse anlegen')}
|
||||
</Button>
|
||||
<Separator className="bg-white/10 my-1" />
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={nextStep}
|
||||
disabled={basketItems.length === 0 && isNextStepDisabled}
|
||||
>
|
||||
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
|
||||
Zurück zum Abrechnungsmodell
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
40
shop/components/wizard/toast-notification.tsx
Normal file
40
shop/components/wizard/toast-notification.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ToastProps {
|
||||
toast: { message: string; type: 'error' | 'success' } | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ToastNotification({ toast, onClose }: ToastProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className={`fixed bottom-5 right-5 z-50 flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md transition-all duration-300 max-w-md ${
|
||||
toast.type === 'error'
|
||||
? 'bg-red-500/20 border-red-500/40 text-red-200'
|
||||
: 'bg-green-500/20 border-green-500/40 text-green-200'
|
||||
}`}
|
||||
>
|
||||
<AlertCircle className={`w-5 h-5 shrink-0 ${toast.type === 'error' ? 'text-red-400' : 'text-green-400'}`} />
|
||||
<p className="text-sm font-medium">{toast.message}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-5 h-5 ml-auto text-slate-400 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { verifyAdmin } from '@/lib/actions/auth'
|
||||
import { Client } from 'pg'
|
||||
|
||||
export async function getDatabaseIntegrity() {
|
||||
try {
|
||||
@@ -36,12 +37,20 @@ export async function repairDatabaseSchema() {
|
||||
export async function optimizeDatabaseIndices() {
|
||||
try {
|
||||
await verifyAdmin()
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase.rpc('optimize_database_indices')
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
return { success: true, data }
|
||||
|
||||
// REINDEX cannot be run inside a transaction block (such as inside a PL/pgSQL function).
|
||||
// Therefore, we connect directly using pg client and execute raw query.
|
||||
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:54322/postgres';
|
||||
const client = new Client({
|
||||
connectionString,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.query('REINDEX SCHEMA public;');
|
||||
await client.end();
|
||||
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error("Error in optimizeDatabaseIndices:", err)
|
||||
return { success: false, error: err.message || "Unerwarteter Fehler bei der Indexoptimierung." }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { headers } from 'next/headers'
|
||||
import { sendLockoutEmail } from '@/lib/utils/email'
|
||||
import { sendMail } from '@/utils/mail'
|
||||
|
||||
export async function signIn(email: string, password: string) {
|
||||
try {
|
||||
@@ -12,12 +14,39 @@ 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 }
|
||||
}
|
||||
|
||||
const userId = data.user?.id
|
||||
if (userId) {
|
||||
let { data: userData, error: userError } = await supabase
|
||||
const adminClient = createAdminClient()
|
||||
let { data: userData, error: userError } = await adminClient
|
||||
.from('users')
|
||||
.select('role')
|
||||
.eq('id', userId)
|
||||
@@ -81,25 +110,92 @@ export async function signOut() {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: string, buttonUrl?: string) {
|
||||
return `
|
||||
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f8fafc; padding: 40px 10px; margin: 0; width: 100%;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||
<!-- Header banner with gradient -->
|
||||
<div style="background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); padding: 32px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px;">${title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 32px; background-color: #ffffff;">
|
||||
${messageHtml}
|
||||
|
||||
${buttonText && buttonUrl ? `
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${buttonUrl}" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 15px; display: inline-block; box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2); transition: all 0.2s ease;">
|
||||
${buttonText}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 32px; border-top: 1px solid #f1f5f9; padding-top: 20px;">
|
||||
Falls der Button nicht funktioniert, kopieren Sie bitte diesen Link in Ihren Browser:<br>
|
||||
<a href="${buttonUrl}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${buttonUrl}</a>
|
||||
</p>
|
||||
` : ''}
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid #f1f5f9; margin: 32px 0;">
|
||||
<p style="color: #94a3b8; font-size: 11px; text-align: center; margin: 0; line-height: 1.6;">
|
||||
Dies ist eine automatisch generierte E-Mail.<br>
|
||||
© ${new Date().getFullYear()} CASPOS Shop. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function resetPassword(email: string) {
|
||||
const supabase = await createClient()
|
||||
const origin = (await headers()).get('origin') || ''
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${origin}/auth/update-password`,
|
||||
})
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
try {
|
||||
const admin = createAdminClient()
|
||||
const { data, error } = await admin.auth.admin.generateLink({
|
||||
type: 'recovery',
|
||||
email,
|
||||
})
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
const tokenHash = (data as any)?.properties?.hashed_token;
|
||||
if (!tokenHash) {
|
||||
return { success: false, error: 'Wiederherstellungs-Token konnte nicht generiert werden.' }
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
|
||||
const resetLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`
|
||||
|
||||
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;">Sie haben das Zurücksetzen Ihres Passworts angefordert.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um ein neues Passwort festzulegen:</p>
|
||||
`;
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort zurücksetzen – CASPOS Shop',
|
||||
text: `Hallo,\n\nSie haben das Zurücksetzen Ihres Passworts angefordert.\n\nBitte klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:\n${resetLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
html: getBeautifulEmailHtml('Passwort zurücksetzen', messageHtml, 'Passwort zurücksetzen', resetLink)
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Password reset error:', err)
|
||||
return { success: false, error: err.message || 'Fehler beim Senden der Passwort-Zurücksetzen-E-Mail.' }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updatePassword(password: string) {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.updateUser({ password })
|
||||
if (error) {
|
||||
throw new Error(error.message)
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.updateUser({ password })
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
console.error('Update password error:', err)
|
||||
return { success: false, error: err.message || 'Fehler beim Aktualisieren des Passworts.' }
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function verifyAdmin() {
|
||||
|
||||
@@ -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()
|
||||
@@ -59,25 +60,11 @@ export async function assignCompanyToUser(userId: string, companyId: string | nu
|
||||
|
||||
export async function getEndCustomersByCompany(companyId: string) {
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 1. Get all partners of this company
|
||||
const { data: partners, error: partnersError } = await admin
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
if (partnersError) throw partnersError
|
||||
|
||||
if (!partners || partners.length === 0) return []
|
||||
|
||||
const partnerIds = partners.map((p) => p.id)
|
||||
|
||||
// 2. Get all end customers belonging to these partners
|
||||
const { data: customers, error: customersError } = await admin
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.in('partner_id', partnerIds)
|
||||
.eq('partner_id', companyId)
|
||||
if (customersError) throw customersError
|
||||
|
||||
return customers || []
|
||||
}
|
||||
|
||||
@@ -91,44 +78,24 @@ export async function getCompanyDetails(companyId: string) {
|
||||
export async function getEndCustomersByCompanyWithPartners(companyId: string) {
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 1. Get all partners of this company
|
||||
const { data: partners, error: partnersError } = await admin
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
if (partnersError) throw partnersError
|
||||
|
||||
if (!partners || partners.length === 0) return []
|
||||
|
||||
const partnerIds = partners.map((p) => p.id)
|
||||
|
||||
// 2. Get all end customers belonging to these partners
|
||||
const { data: customers, error: customersError } = await admin
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.in('partner_id', partnerIds)
|
||||
.eq('partner_id', companyId)
|
||||
if (customersError) throw customersError
|
||||
|
||||
// 3. Get profiles of all these partners to resolve their names
|
||||
const { data: profiles, error: profilesError } = await admin
|
||||
.from('profiles')
|
||||
.select('id, first_name, last_name')
|
||||
.in('id', partnerIds)
|
||||
if (profilesError) throw profilesError
|
||||
const { data: company } = await admin
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
// Get user emails
|
||||
const { data: { users }, error: authError } = await admin.auth.admin.listUsers()
|
||||
const emailMap = new Map((users || []).map(u => [u.id, u.email || '']))
|
||||
const companyName = company?.name || 'Unbekannt'
|
||||
|
||||
const profileMap = new Map((profiles || []).map((p) => [p.id, p]))
|
||||
|
||||
return customers.map((c) => {
|
||||
const prof = profileMap.get(c.partner_id)
|
||||
return {
|
||||
...c,
|
||||
partner_name: prof ? `${prof.first_name || ''} ${prof.last_name || ''}`.trim() || emailMap.get(c.partner_id) || 'Unbekannt' : emailMap.get(c.partner_id) || 'Unbekannt',
|
||||
}
|
||||
})
|
||||
return customers.map((c) => ({
|
||||
...c,
|
||||
partner_name: companyName,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getCompanyPartners(companyId: string) {
|
||||
@@ -174,6 +141,10 @@ export async function adminCreateEndCustomer(data: {
|
||||
zip?: string
|
||||
city?: string
|
||||
email?: string
|
||||
bank_iban?: string
|
||||
bank_bic?: string
|
||||
bank_name?: string
|
||||
bank_owner?: string
|
||||
}) {
|
||||
const admin = createAdminClient()
|
||||
const { data: dbData, error } = await admin
|
||||
@@ -188,6 +159,10 @@ export async function adminCreateEndCustomer(data: {
|
||||
zip: data.zip || null,
|
||||
city: data.city || null,
|
||||
email: data.email || null,
|
||||
bank_iban: data.bank_iban || null,
|
||||
bank_bic: data.bank_bic || null,
|
||||
bank_name: data.bank_name || null,
|
||||
bank_owner: data.bank_owner || null,
|
||||
}])
|
||||
.select()
|
||||
.single()
|
||||
@@ -205,6 +180,10 @@ export async function adminUpdateEndCustomer(id: string, data: {
|
||||
zip?: string
|
||||
city?: string
|
||||
email?: string
|
||||
bank_iban?: string
|
||||
bank_bic?: string
|
||||
bank_name?: string
|
||||
bank_owner?: string
|
||||
}) {
|
||||
const admin = createAdminClient()
|
||||
const { data: dbData, error } = await admin
|
||||
@@ -219,6 +198,10 @@ export async function adminUpdateEndCustomer(id: string, data: {
|
||||
zip: data.zip || null,
|
||||
city: data.city || null,
|
||||
email: data.email || null,
|
||||
bank_iban: data.bank_iban || null,
|
||||
bank_bic: data.bank_bic || null,
|
||||
bank_name: data.bank_name || null,
|
||||
bank_owner: data.bank_owner || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.select()
|
||||
@@ -237,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 }
|
||||
}
|
||||
|
||||
|
||||
|
||||
150
shop/lib/actions/email-templates.ts
Normal file
150
shop/lib/actions/email-templates.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
interface EmailDetails {
|
||||
orderNumber: string
|
||||
formattedDate: string
|
||||
customerCompanyName: string
|
||||
totalDetailsText: string
|
||||
totalDetailsHtml: string
|
||||
itemsDetailsText?: string
|
||||
itemsDetailsHtml?: string
|
||||
}
|
||||
|
||||
export function getOrderEmailTemplate(
|
||||
details: EmailDetails,
|
||||
siteUrl: string,
|
||||
isUpdate: boolean = false
|
||||
) {
|
||||
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten'
|
||||
const intro = isUpdate
|
||||
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
|
||||
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
|
||||
|
||||
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.itemsDetailsText || ''}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<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.itemsDetailsHtml || ''}
|
||||
${details.totalDetailsHtml}
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
|
||||
<a href="${siteUrl}/api/orders/${details.orderNumber}/download" style="color: #3b82f6; text-decoration: underline; font-weight: 500;">Anfragebestätigung PDF herunterladen</a>
|
||||
</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
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`
|
||||
|
||||
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>
|
||||
`
|
||||
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
export function buildEmailItemsSection(items: any[]) {
|
||||
const groupedItems: { [key: string]: any[] } = {}
|
||||
items.forEach((item: any) => {
|
||||
const devName = item.device_name || 'Kasse 1'
|
||||
if (!groupedItems[devName]) {
|
||||
groupedItems[devName] = []
|
||||
}
|
||||
groupedItems[devName].push(item)
|
||||
})
|
||||
|
||||
let itemsDetailsText = '\nKassen-Konfigurationen:'
|
||||
let itemsDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
|
||||
itemsDetailsText += `\n- Kasse: ${deviceName}`
|
||||
itemsDetailsHtml += `
|
||||
<tr style="background-color: #f1f5f9;">
|
||||
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: ${deviceName}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
devItems.forEach((item: any) => {
|
||||
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
|
||||
itemsDetailsHtml += `
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
|
||||
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
|
||||
</td>
|
||||
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
|
||||
${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
item.selected_modules?.forEach((mod: any) => {
|
||||
const qty = mod.quantity || 1
|
||||
const price = mod.total_price ?? (mod.price * qty)
|
||||
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
|
||||
itemsDetailsHtml += `
|
||||
<tr>
|
||||
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
|
||||
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
|
||||
</td>
|
||||
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
|
||||
+${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
text: itemsDetailsText + '\n',
|
||||
html: itemsDetailsHtml
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export type EndCustomerFormData = {
|
||||
zip?: string
|
||||
city?: string
|
||||
email?: string
|
||||
bank_iban?: string
|
||||
bank_bic?: string
|
||||
bank_name?: string
|
||||
bank_owner?: string
|
||||
}
|
||||
|
||||
// ─── Lesen ───────────────────────────────────────────────────────────────────
|
||||
@@ -26,9 +30,23 @@ export type EndCustomerFormData = {
|
||||
*/
|
||||
export async function getEndCustomers(): Promise<EndCustomer[]> {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { data: dbUser, error: dbUserError } = await supabase
|
||||
.from('users')
|
||||
.select('company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (dbUserError || !dbUser || !dbUser.company_id) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.eq('partner_id', dbUser.company_id)
|
||||
.order('company_name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
@@ -56,16 +74,32 @@ export async function getEndCustomer(id: string): Promise<EndCustomer | null> {
|
||||
* Legt einen neuen Endkunden für den eingeloggten Partner an.
|
||||
* partner_id wird serverseitig aus der Session gesetzt (kein Client-Trust).
|
||||
*/
|
||||
export async function createEndCustomer(formData: EndCustomerFormData): Promise<EndCustomer> {
|
||||
export async function createEndCustomer(formData: EndCustomerFormData, companyId?: string): Promise<EndCustomer> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
const resolvedCompanyId = dbUser?.company_id || companyId
|
||||
|
||||
if (!isAdmin && !resolvedCompanyId) {
|
||||
throw new Error('Kein Unternehmen zugewiesen.')
|
||||
}
|
||||
|
||||
// Admin ohne company_id braucht adminClient (RLS erlaubt kein INSERT ohne passende partner_id)
|
||||
const client = (!dbUser?.company_id && isAdmin) ? createAdminClient() : supabase
|
||||
|
||||
const { data, error } = await client
|
||||
.from('end_customers')
|
||||
.insert([{
|
||||
partner_id: user.id,
|
||||
partner_id: resolvedCompanyId || user.id,
|
||||
company_name: formData.company_name,
|
||||
vat_id: formData.vat_id || null,
|
||||
first_name: formData.first_name || null,
|
||||
@@ -74,6 +108,10 @@ export async function createEndCustomer(formData: EndCustomerFormData): Promise<
|
||||
zip: formData.zip || null,
|
||||
city: formData.city || null,
|
||||
email: formData.email || null,
|
||||
bank_iban: formData.bank_iban || null,
|
||||
bank_bic: formData.bank_bic || null,
|
||||
bank_name: formData.bank_name || null,
|
||||
bank_owner: formData.bank_owner || null,
|
||||
}])
|
||||
.select()
|
||||
.single()
|
||||
@@ -104,6 +142,10 @@ export async function updateEndCustomer(id: string, formData: EndCustomerFormDat
|
||||
zip: formData.zip || null,
|
||||
city: formData.city || null,
|
||||
email: formData.email || null,
|
||||
bank_iban: formData.bank_iban || null,
|
||||
bank_bic: formData.bank_bic || null,
|
||||
bank_name: formData.bank_name || null,
|
||||
bank_owner: formData.bank_owner || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
@@ -131,13 +173,17 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
||||
.from('end_customers')
|
||||
.update({
|
||||
company_name: '[GELÖSCHT]',
|
||||
vat_id: null,
|
||||
vat_id: '[GELÖSCHT]',
|
||||
first_name: '[GELÖSCHT]',
|
||||
last_name: null,
|
||||
street: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
email: null,
|
||||
last_name: '[GELÖSCHT]',
|
||||
street: '[GELÖSCHT]',
|
||||
zip: '[GELÖSCHT]',
|
||||
city: '[GELÖSCHT]',
|
||||
email: '[GELÖSCHT]',
|
||||
bank_iban: '[GELÖSCHT]',
|
||||
bank_bic: '[GELÖSCHT]',
|
||||
bank_name: '[GELÖSCHT]',
|
||||
bank_owner: '[GELÖSCHT]',
|
||||
is_anonymized: true,
|
||||
})
|
||||
.eq('id', id)
|
||||
@@ -151,10 +197,18 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
|
||||
|
||||
export async function getPartnerEndCustomers(partnerId: string): Promise<EndCustomer[]> {
|
||||
const admin = createAdminClient()
|
||||
const { data: dbUser } = await admin
|
||||
.from('users')
|
||||
.select('company_id')
|
||||
.eq('id', partnerId)
|
||||
.single()
|
||||
|
||||
const targetId = dbUser?.company_id || partnerId
|
||||
|
||||
const { data, error } = await admin
|
||||
.from('end_customers')
|
||||
.select('*')
|
||||
.eq('partner_id', partnerId)
|
||||
.eq('partner_id', targetId)
|
||||
.order('company_name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createHash } from 'crypto'
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { sendMail } from '@/utils/mail'
|
||||
@@ -10,9 +11,13 @@ import React from 'react'
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||
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, buildEmailItemsSection } from '@/lib/actions/email-templates'
|
||||
|
||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
|
||||
@@ -21,22 +26,16 @@ import { getProducts, getCategories } from '@/lib/actions/products'
|
||||
function generateOrderNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
||||
return `BE-${year}-${rand}`
|
||||
return `AE-${year}-${rand}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen einfachen deterministischen Hash aus dem Bestell-Snapshot.
|
||||
* Erstellt einen SHA-256 Hash aus dem Bestell-Snapshot.
|
||||
* Wird für den Idempotenz-Guard verwendet (verhindert Doppelbestellungen).
|
||||
*/
|
||||
function hashOrderSnapshot(snapshot: object): string {
|
||||
const str = JSON.stringify(snapshot)
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36)
|
||||
return createHash('sha256').update(str).digest('hex')
|
||||
}
|
||||
|
||||
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
||||
@@ -66,93 +65,40 @@ export async function submitOrder(params: {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
// Check if user is assigned to a company or is admin
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdminUser = dbUser?.role === 'admin'
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
throw new Error('Sie müssen einer Firma zugewiesen sein, um eine Anfrage zu erstellen.')
|
||||
}
|
||||
|
||||
// Fetch catalog directly from DB to prevent client tampering
|
||||
const dbProducts = await getProducts()
|
||||
const dbCategories = await getCategories()
|
||||
|
||||
// ─── Constraint Validation ──────────────────────────────────────────────────
|
||||
for (const cat of dbCategories) {
|
||||
const sel = selections[cat.id]
|
||||
if (cat.is_required && (!sel || !sel.productId)) {
|
||||
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
||||
}
|
||||
if (sel?.productId) {
|
||||
const prod = dbProducts.find(p => p.id === sel.productId)
|
||||
if (!prod) {
|
||||
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
|
||||
}
|
||||
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) {
|
||||
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
|
||||
}
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
|
||||
}
|
||||
}
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Product-level constraints validation
|
||||
const selectedProductIds = Object.values(selections)
|
||||
.map(s => s.productId)
|
||||
.filter((id): id is string => !!id)
|
||||
|
||||
for (const prodId of selectedProductIds) {
|
||||
const prod = dbProducts.find(p => p.id === prodId)
|
||||
if (!prod) continue
|
||||
|
||||
// Product requirements check
|
||||
if (prod.requirements && prod.requirements.length > 0) {
|
||||
const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId))
|
||||
if (missing.length > 0) {
|
||||
const missingNames = missing
|
||||
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
|
||||
.join(', ')
|
||||
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von: ${missingNames}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Product exclusions check
|
||||
if (prod.exclusions && prod.exclusions.length > 0) {
|
||||
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
const conflictingNames = conflicting
|
||||
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
|
||||
.join(', ')
|
||||
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
validateWizardSelections(selections, dbProducts, dbCategories)
|
||||
|
||||
// 1. Snapshots aufbauen
|
||||
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
||||
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
|
||||
|
||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 5 Minuten existiert
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString()
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString()
|
||||
|
||||
const { data: existingOrder } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('order_hash', orderHash)
|
||||
.gte('created_at', thirtySecondsAgo)
|
||||
.gte('created_at', fiveMinutesAgo)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingOrder) {
|
||||
@@ -181,6 +127,7 @@ export async function submitOrder(params: {
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
company_id: dbUser?.company_id || null,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
end_customer_id: endCustomerId ?? null,
|
||||
@@ -205,7 +152,7 @@ export async function submitOrder(params: {
|
||||
)
|
||||
|
||||
// 6. PDF in Supabase Storage hochladen
|
||||
const fileName = `angebotsbestaetigung_${order.id}.pdf`
|
||||
const fileName = `ab_${order.id}.pdf`
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
@@ -214,56 +161,123 @@ export async function submitOrder(params: {
|
||||
if (uploadError) {
|
||||
console.error('PDF Upload Error:', uploadError)
|
||||
} else {
|
||||
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
||||
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||
order.pdf_url = publicUrlData.publicUrl
|
||||
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id)
|
||||
order.pdf_url = fileName
|
||||
}
|
||||
|
||||
// 7. E-Mail mit PDF-Anhang an den Besteller senden
|
||||
if (user.email) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
|
||||
const formattedTotal = order.total_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19
|
||||
const items = orderSnapshot.items ?? []
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time')
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly')
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
|
||||
|
||||
let totalDetailsText = ''
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
||||
}
|
||||
|
||||
let totalDetailsHtml = ''
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items)
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Angebotsbestätigung ${orderNumber}`,
|
||||
text: `Hallo,\n\nvielen Dank für Ihre Bestellung bei CASPOS Shop!\n\nBestelldetails:\n- Bestellnummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n- Gesamtsumme: ${formattedTotal}\n\nIm Anhang dieser E-Mail finden Sie Ihre Angebotsbestätigung als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
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;">Vielen Dank für Ihre Bestellung!</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;">wir freuen uns sehr über Ihre Bestellung bei CASPOS Shop. Ihre Bestellung wurde erfolgreich entgegengenommen und wird nun verarbeitet.</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;">Bestelldetails</h3>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
|
||||
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
|
||||
<td style="padding: 4px 0;">${formattedDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
|
||||
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Gesamtsumme:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a;">${formattedTotal}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre Angebotsbestätigung als PDF-Dokument.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
|
||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||
</div>
|
||||
`,
|
||||
subject: `Anfragebestätigung ${orderNumber}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebotsbestaetigung_${orderNumber}.pdf`,
|
||||
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
@@ -284,7 +298,7 @@ export async function submitOrder(params: {
|
||||
|
||||
export async function updateOrderStatus(
|
||||
orderId: string,
|
||||
newStatus: 'pending' | 'active' | 'completed' | 'cancelled'
|
||||
newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
|
||||
) {
|
||||
const admin = createAdminClient()
|
||||
|
||||
@@ -321,7 +335,7 @@ export async function updateOrderStatus(
|
||||
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> = {
|
||||
@@ -329,40 +343,18 @@ export async function updateOrderStatus(
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
const oldLabel = statusLabelMap[oldStatus] || oldStatus
|
||||
const newLabel = statusLabelMap[newStatus] || newStatus
|
||||
|
||||
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel)
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
|
||||
text: `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<h2 style="color: #0f172a; margin-bottom: 16px;">Status Ihrer Bestellung hat sich geändert</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status Ihrer Bestellung mit der Nummer <strong>${orderNumber}</strong> wurde aktualisiert.</p>
|
||||
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
|
||||
<td style="padding: 4px 0;">${orderNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Alter Status:</td>
|
||||
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold;">Neuer Status:</td>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Ihr CASPOS Shop-Team</p>
|
||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||
</div>
|
||||
`
|
||||
subject: `Statusänderung Ihrer Anfrage ${orderNumber}`,
|
||||
text: statusEmail.text,
|
||||
html: statusEmail.html
|
||||
})
|
||||
}
|
||||
} catch (mailError) {
|
||||
@@ -374,3 +366,258 @@ export async function updateOrderStatus(
|
||||
revalidatePath('/my-orders')
|
||||
return updatedOrder as Order
|
||||
}
|
||||
|
||||
export async function updateOrder(
|
||||
orderId: string,
|
||||
params: {
|
||||
selections: WizardSelections
|
||||
moduleQuantities?: Record<string, number>
|
||||
customerProfile: Partial<Profile>
|
||||
endCustomerId?: string | null
|
||||
endCustomer?: EndCustomer | null
|
||||
billingInterval?: 'one_time' | 'monthly'
|
||||
lastLicenseDate?: string | null
|
||||
companyId?: string | null
|
||||
}
|
||||
): Promise<Order> {
|
||||
const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate, companyId } = params
|
||||
const supabase = await createClient()
|
||||
const admin = createAdminClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) throw new Error('Not authenticated')
|
||||
|
||||
// 1. Fetch current order & verify permissions
|
||||
const { data: existingOrder, error: fetchError } = await admin
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('id', orderId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !existingOrder) {
|
||||
throw new Error(`Bestellung nicht gefunden: ${fetchError?.message || 'Unbekannt'}`)
|
||||
}
|
||||
|
||||
const { data: dbUser } = await admin
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = dbUser?.role === 'admin'
|
||||
let isAuthorized = existingOrder.user_id === user.id
|
||||
|
||||
if (!isAuthorized && dbUser?.company_id && existingOrder.user_id) {
|
||||
const { data: creatorUser } = await admin
|
||||
.from('users')
|
||||
.select('company_id')
|
||||
.eq('id', existingOrder.user_id)
|
||||
.single()
|
||||
|
||||
if (creatorUser?.company_id === dbUser.company_id) {
|
||||
isAuthorized = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin && !isAuthorized) {
|
||||
throw new Error('Keine Berechtigung dieses Anfrage zu bearbeiten.')
|
||||
}
|
||||
|
||||
if (!isAdmin && !dbUser?.company_id) {
|
||||
throw new Error('Sie müssen einer Firma zugewiesen sein, um diese Anfrage zu bearbeiten.')
|
||||
}
|
||||
|
||||
if (existingOrder.status === 'completed' && !isAdmin) {
|
||||
throw new Error('Abgeschlossene Anfragen können nicht mehr bearbeitet werden.')
|
||||
}
|
||||
|
||||
// Fetch catalog directly from DB to prevent client tampering
|
||||
const dbProducts = await getProducts()
|
||||
const dbCategories = await getCategories()
|
||||
|
||||
// ─── Constraint Validation ──────────────────────────────────────────────────
|
||||
validateWizardSelections(selections, dbProducts, dbCategories)
|
||||
|
||||
// 1. Snapshots aufbauen
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
|
||||
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
|
||||
// 2. Bestellung in DB aktualisieren
|
||||
const updatePayload: any = {
|
||||
end_customer_id: endCustomerId ?? null,
|
||||
total_price: orderSnapshot.total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
order_hash: orderHash,
|
||||
}
|
||||
|
||||
if (isAdmin && companyId !== undefined) {
|
||||
updatePayload.company_id = companyId
|
||||
}
|
||||
|
||||
const { data: order, error: orderError } = await admin
|
||||
.from('orders')
|
||||
.update(updatePayload)
|
||||
.eq('id', orderId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (orderError || !order) throw orderError || new Error('Fehler beim Aktualisieren der Bestellung.')
|
||||
|
||||
// 3. PDF generieren und überschreiben
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
order,
|
||||
customer: customerSnapshot,
|
||||
orderSnapshot,
|
||||
})
|
||||
)
|
||||
|
||||
// PDF hochladen (upsert: true, um das alte zu ersetzen)
|
||||
const fileName = `ab_${order.id}.pdf`
|
||||
const { error: uploadError } = await supabase
|
||||
.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', order.id)
|
||||
order.pdf_url = fileName
|
||||
}
|
||||
|
||||
// 4. E-Mail mit PDF-Anhang an den Besteller senden (Info über Änderung)
|
||||
const orderUserEmail = user.email || (await admin.auth.admin.getUserById(existingOrder.user_id).then(res => res.data.user?.email))
|
||||
if (orderUserEmail) {
|
||||
try {
|
||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE')
|
||||
|
||||
const taxRate = orderSnapshot.tax_rate ?? 19
|
||||
const items = orderSnapshot.items ?? []
|
||||
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time')
|
||||
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly')
|
||||
|
||||
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
||||
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100
|
||||
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100
|
||||
|
||||
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0)
|
||||
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100
|
||||
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100
|
||||
|
||||
let totalDetailsText = ''
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`
|
||||
} else {
|
||||
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`
|
||||
}
|
||||
|
||||
let totalDetailsHtml = ''
|
||||
if (oneTimeNet > 0 && monthlyNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`
|
||||
} else if (oneTimeNet > 0) {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
`
|
||||
} else {
|
||||
totalDetailsHtml = `
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
|
||||
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
|
||||
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
const itemsSection = buildEmailItemsSection(items)
|
||||
|
||||
const emailTemplate = getOrderEmailTemplate({
|
||||
orderNumber: order.order_number,
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
totalDetailsText,
|
||||
totalDetailsHtml,
|
||||
itemsDetailsText: itemsSection.text,
|
||||
itemsDetailsHtml: itemsSection.html
|
||||
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
|
||||
|
||||
await sendMail({
|
||||
to: orderUserEmail,
|
||||
subject: `Anfrageänderung ${order.order_number}`,
|
||||
text: emailTemplate.text,
|
||||
html: emailTemplate.html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send order update confirmation mail:', mailError)
|
||||
}
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.error('PDF Re-generation Error:', pdfError)
|
||||
}
|
||||
|
||||
revalidatePath('/my-customers')
|
||||
revalidatePath('/my-orders')
|
||||
revalidatePath('/admin/orders')
|
||||
return order as Order
|
||||
}
|
||||
|
||||
@@ -206,3 +206,61 @@ export async function transferModules(
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
export async function addProductDependency(
|
||||
productId: string,
|
||||
type: 'requirements' | 'exclusions',
|
||||
dependencyId: string
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: product, error: fetchError } = await supabase
|
||||
.from('products')
|
||||
.select('requirements, exclusions')
|
||||
.eq('id', productId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
|
||||
const currentDeps = (product[type] || []) as string[]
|
||||
if (!currentDeps.includes(dependencyId)) {
|
||||
const updatedDeps = [...currentDeps, dependencyId]
|
||||
const { error: updateError } = await supabase
|
||||
.from('products')
|
||||
.update({ [type]: updatedDeps })
|
||||
.eq('id', productId)
|
||||
if (updateError) throw updateError
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
export async function removeProductDependency(
|
||||
productId: string,
|
||||
type: 'requirements' | 'exclusions',
|
||||
dependencyId: string
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: product, error: fetchError } = await supabase
|
||||
.from('products')
|
||||
.select('requirements, exclusions')
|
||||
.eq('id', productId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
|
||||
const currentDeps = (product[type] || []) as string[]
|
||||
if (currentDeps.includes(dependencyId)) {
|
||||
const updatedDeps = currentDeps.filter(id => id !== dependencyId)
|
||||
const { error: updateError } = await supabase
|
||||
.from('products')
|
||||
.update({ [type]: updatedDeps })
|
||||
.eq('id', productId)
|
||||
if (updateError) throw updateError
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/order')
|
||||
}
|
||||
|
||||
140
shop/lib/actions/setup.ts
Normal file
140
shop/lib/actions/setup.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'use server'
|
||||
|
||||
import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export type AdminSetupData = {
|
||||
email: string
|
||||
password: string
|
||||
companyName: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export type SmtpSetupData = {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any user exists in the public.users table.
|
||||
* If count is 0, setup is required.
|
||||
*/
|
||||
export async function isSetupNeeded(): Promise<boolean> {
|
||||
try {
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 1. Check if any user exists in Supabase Auth
|
||||
const { data: authData, error: authError } = await admin.auth.admin.listUsers({
|
||||
perPage: 1
|
||||
})
|
||||
|
||||
if (authError) {
|
||||
console.error('Error checking auth users list:', authError)
|
||||
return false
|
||||
}
|
||||
|
||||
if (authData.users.length > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Check if any user exists in public.users table
|
||||
const { count, error: dbError } = await admin
|
||||
.from('users')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
if (dbError) {
|
||||
console.error('Error checking users table status:', dbError)
|
||||
return false
|
||||
}
|
||||
|
||||
return count === 0
|
||||
} catch (e) {
|
||||
console.error('Exception checking setup status:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the initial setup by creating the first admin user,
|
||||
* updating their role and profile, and storing SMTP settings.
|
||||
*/
|
||||
export async function completeSetup(
|
||||
adminData: AdminSetupData,
|
||||
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.' }
|
||||
}
|
||||
|
||||
const admin = createAdminClient()
|
||||
|
||||
// 2. Create user in Supabase Auth
|
||||
const { data: authData, error: authError } = await admin.auth.admin.createUser({
|
||||
email: adminData.email,
|
||||
password: adminData.password,
|
||||
email_confirm: true,
|
||||
})
|
||||
|
||||
if (authError || !authData.user) {
|
||||
return { success: false, error: `Fehler beim Erstellen des Admin-Kontos: ${authError?.message}` }
|
||||
}
|
||||
|
||||
const userId = authData.user.id
|
||||
|
||||
// 3. Update user role to admin in public.users
|
||||
const { error: roleError } = await admin
|
||||
.from('users')
|
||||
.update({ role: 'admin' })
|
||||
.eq('id', userId)
|
||||
|
||||
if (roleError) {
|
||||
console.error('Error setting user role to admin:', roleError)
|
||||
}
|
||||
|
||||
// 4. Update company and name details in public.profiles
|
||||
const { error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.update({
|
||||
company_name: adminData.companyName,
|
||||
first_name: adminData.firstName,
|
||||
last_name: adminData.lastName,
|
||||
email: adminData.email,
|
||||
})
|
||||
.eq('id', userId)
|
||||
|
||||
if (profileError) {
|
||||
console.error('Error updating admin profile:', profileError)
|
||||
}
|
||||
|
||||
// 5. Store SMTP configuration in public.settings
|
||||
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)
|
||||
return { success: false, error: `Fehler beim Speichern der SMTP-Einstellungen: ${smtpError.message}` }
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
console.error('Exception during completeSetup:', e)
|
||||
return { success: false, error: e.message || 'Unerwarteter Fehler beim Setup.' }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use server';
|
||||
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { revalidatePath, unstable_noStore as noStore } from 'next/cache';
|
||||
import { sendMail } from '@/utils/mail';
|
||||
|
||||
@@ -49,70 +50,78 @@ export async function getUsers() {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createUser(data: { email: string; role?: 'admin' | 'partner'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) {
|
||||
const admin = createAdminClient();
|
||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
||||
email: data.email,
|
||||
password: Math.random().toString(36).slice(-10) + Math.random().toString(36).slice(-10),
|
||||
email_confirm: data.email_confirm ?? true,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!user) throw new Error('Benutzer konnte nicht erstellt werden.');
|
||||
|
||||
const role = data.role || 'partner';
|
||||
const { error: dbError } = await admin
|
||||
.from('users')
|
||||
.upsert({ id: user.id, role, company_id: data.company_id || null }, { onConflict: 'id' });
|
||||
if (dbError) throw dbError;
|
||||
|
||||
const { error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: user.id,
|
||||
first_name: data.first_name || null,
|
||||
last_name: data.last_name || null,
|
||||
}, { onConflict: 'id' });
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Generate recovery link to let user set password
|
||||
export async function createUser(data: { email: string; role?: 'admin' | 'partner' | 'verwaltung'; email_confirm?: boolean; company_id?: string; first_name?: string; last_name?: string }) {
|
||||
try {
|
||||
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
|
||||
type: 'recovery',
|
||||
const admin = createAdminClient();
|
||||
const { data: { user }, error } = await admin.auth.admin.createUser({
|
||||
email: data.email,
|
||||
password: Math.random().toString(36).slice(-10) + Math.random().toString(36).slice(-10),
|
||||
email_confirm: data.email_confirm ?? true,
|
||||
});
|
||||
if (!linkError && linkData) {
|
||||
const tokenHash = (linkData as any)?.properties?.hashed_token;
|
||||
if (tokenHash) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de';
|
||||
const setupLink = `${siteUrl}/auth/confirm?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
|
||||
await sendMail({
|
||||
to: data.email,
|
||||
subject: 'Willkommen bei CASPOS Shop – Ihr Benutzerkonto wurde erstellt',
|
||||
text: `Hallo,\n\nein neues Benutzerkonto wurde für Sie auf CASPOS Shop erstellt.\n\nBitte klicken Sie auf den folgenden Link, um Ihr persönliches Passwort festzulegen und Ihr Konto zu aktivieren:\n${setupLink}\n\nDieser Link ist zeitlich begrenzt gültig.\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
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;">Willkommen bei CASPOS Shop!</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;">für Sie wurde erfolgreich ein neues Benutzerkonto auf unserer Plattform eingerichtet.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Bitte klicken Sie auf den untenstehenden Button, um Ihr Passwort festzulegen und Ihr Konto zu aktivieren:</p>
|
||||
<div style="margin: 24px 0;">
|
||||
<a href="${setupLink}" style="background-color: #3b82f6; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">Passwort jetzt festlegen</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 14px; line-height: 1.5;">Falls der Button nicht funktioniert, können Sie auch folgenden Link in Ihren Browser kopieren:<br>
|
||||
<a href="${setupLink}" style="color: #3b82f6;">${setupLink}</a></p>
|
||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
|
||||
<p style="color: #94a3b8; font-size: 12px;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
if (!user) {
|
||||
return { success: false, error: 'Benutzer konnte nicht erstellt werden.' };
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send welcome setup mail:', mailError);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
return user;
|
||||
const role = data.role || 'partner';
|
||||
const { error: dbError } = await admin
|
||||
.from('users')
|
||||
.upsert({ id: user.id, role, company_id: data.company_id || null }, { onConflict: 'id' });
|
||||
if (dbError) {
|
||||
return { success: false, error: dbError.message };
|
||||
}
|
||||
|
||||
const { error: profileError } = await admin
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: user.id,
|
||||
first_name: data.first_name || null,
|
||||
last_name: data.last_name || null,
|
||||
}, { onConflict: 'id' });
|
||||
if (profileError) {
|
||||
return { success: false, error: profileError.message };
|
||||
}
|
||||
|
||||
// Generate recovery link to let user set password
|
||||
try {
|
||||
const { data: linkData, error: linkError } = await admin.auth.admin.generateLink({
|
||||
type: 'recovery',
|
||||
email: data.email,
|
||||
});
|
||||
if (!linkError && linkData) {
|
||||
const tokenHash = (linkData as any)?.properties?.hashed_token;
|
||||
if (tokenHash) {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de';
|
||||
const setupLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
|
||||
await sendMail({
|
||||
to: data.email,
|
||||
subject: 'Willkommen bei CASPOS Shop – Ihr Benutzerkonto wurde erstellt',
|
||||
text: `Hallo,\n\nein neues Benutzerkonto wurde für Sie auf CASPOS Shop erstellt.\n\nBitte klicken Sie auf den folgenden Link, um Ihr persönliches Passwort festzulegen und Ihr Konto zu aktivieren:\n${setupLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
html: getBeautifulEmailHtml(
|
||||
'Willkommen bei CASPOS Shop',
|
||||
`
|
||||
<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;">für Sie wurde erfolgreich ein neues Benutzerkonto auf unserer Plattform eingerichtet.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um Ihr Passwort festzulegen und Ihr Konto zu aktivieren:</p>
|
||||
`,
|
||||
'Passwort festlegen',
|
||||
setupLink
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send welcome setup mail:', mailError);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true, user };
|
||||
} catch (err: any) {
|
||||
console.error('Error creating user in action:', err);
|
||||
return { success: false, error: err.message || 'Ein unerwarteter Fehler ist aufgetreten.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
@@ -130,35 +139,44 @@ export async function deleteUser(id: string) {
|
||||
}
|
||||
|
||||
if (count && count > 0) {
|
||||
throw new Error('Auf diesen User gibt es bereits Belege. Sperren möglich.');
|
||||
return { success: false, error: 'Auf diesen User gibt es bereits Belege. Sperren möglich.' };
|
||||
}
|
||||
|
||||
// Delete from public.users first to satisfy foreign key constraints
|
||||
const { error: dbError } = await admin.from('users').delete().eq('id', id);
|
||||
if (dbError) {
|
||||
console.error('Database delete user error:', dbError);
|
||||
throw new Error(`Datenbank-Fehler beim Löschen des Benutzers: ${dbError.message}`);
|
||||
return { success: false, error: `Datenbank-Fehler beim Löschen des Benutzers: ${dbError.message}` };
|
||||
}
|
||||
|
||||
const { error } = await admin.auth.admin.deleteUser(id);
|
||||
if (error) {
|
||||
console.error('Auth delete user error:', error);
|
||||
throw new Error(`Auth-Fehler beim Löschen des Benutzers: ${error.message}`);
|
||||
return { success: false, error: `Auth-Fehler beim Löschen des Benutzers: ${error.message}` };
|
||||
}
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('deleteUser Server Action failed:', err);
|
||||
throw new Error(err.message || 'Benutzer konnte nicht gelöscht werden.');
|
||||
return { success: false, error: err.message || 'Benutzer konnte nicht gelöscht werden.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserRole(id: string, role: 'admin' | 'partner' | 'gesperrt') {
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin
|
||||
.from('users')
|
||||
.upsert({ id, role }, { onConflict: 'id' });
|
||||
if (error) throw error;
|
||||
revalidatePath('/admin/users');
|
||||
export async function updateUserRole(id: string, role: 'admin' | 'partner' | 'verwaltung' | 'gesperrt') {
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin
|
||||
.from('users')
|
||||
.upsert({ id, role }, { onConflict: 'id' });
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('updateUserRole Server Action failed:', err);
|
||||
return { success: false, error: err.message || 'Rolle konnte nicht aktualisiert werden.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserStatus(id: string, ban: boolean) {
|
||||
@@ -183,12 +201,17 @@ export async function resetPassword(email: string) {
|
||||
if (!tokenHash) throw new Error('Recovery token not generated');
|
||||
// Build link pointing to our own /auth/confirm route
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de';
|
||||
const resetLink = `${siteUrl}/auth/confirm?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
|
||||
const resetLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`;
|
||||
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;">der Administrator hat das Zurücksetzen Ihres Passworts angefordert.</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um ein neues Passwort für Ihr Konto festzulegen:</p>
|
||||
`;
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort zurücksetzen',
|
||||
text: `Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: ${resetLink}`,
|
||||
html: `<p>Klicken Sie <a href="${resetLink}">hier</a>, um Ihr Passwort zurückzusetzen.</p>`,
|
||||
subject: 'Passwort zurücksetzen – CASPOS Shop',
|
||||
text: `Hallo,\n\nder Administrator hat das Zurücksetzen Ihres Passworts angefordert.\n\nBitte klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:\n${resetLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
|
||||
html: getBeautifulEmailHtml('Passwort zurücksetzen', messageHtml, 'Passwort zurücksetzen', resetLink),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,11 +247,100 @@ export async function getPartners() {
|
||||
}
|
||||
|
||||
export async function updateUserProfile(id: string, data: { first_name: string | null; last_name: string | null }) {
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin
|
||||
.from('profiles')
|
||||
.upsert({ id, first_name: data.first_name, last_name: data.last_name }, { onConflict: 'id' });
|
||||
if (error) throw error;
|
||||
revalidatePath('/admin/users');
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin
|
||||
.from('profiles')
|
||||
.upsert({ id, first_name: data.first_name, last_name: data.last_name }, { onConflict: 'id' });
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
revalidatePath('/admin/users');
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('updateUserProfile Server Action failed:', err);
|
||||
return { success: false, error: err.message || 'Profil konnte nicht aktualisiert werden.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCompanyDetails(companyId: string, data: { street: string | null; zip: string | null; city: string | null; email: string | null }) {
|
||||
try {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht authentifiziert.' };
|
||||
}
|
||||
|
||||
// Berechtigung prüfen: Ist der User Mitglied dieser Company oder Admin?
|
||||
const { data: dbUser } = await supabase
|
||||
.from('users')
|
||||
.select('role, company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (!dbUser) {
|
||||
return { success: false, error: 'Benutzer nicht gefunden.' };
|
||||
}
|
||||
|
||||
if (dbUser.role !== 'admin' && dbUser.company_id !== companyId) {
|
||||
return { success: false, error: 'Keine Berechtigung zur Bearbeitung dieses Unternehmens.' };
|
||||
}
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { error } = await admin
|
||||
.from('companies')
|
||||
.update({
|
||||
street: data.street,
|
||||
zip: data.zip,
|
||||
city: data.city,
|
||||
email: data.email
|
||||
})
|
||||
.eq('id', companyId);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('updateCompanyDetails Server Action failed:', err);
|
||||
return { success: false, error: err.message || 'Unternehmen konnte nicht aktualisiert werden.' };
|
||||
}
|
||||
}
|
||||
|
||||
function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: string, buttonUrl?: string) {
|
||||
return `
|
||||
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f8fafc; padding: 40px 10px; margin: 0; width: 100%;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
|
||||
<!-- Header banner with gradient -->
|
||||
<div style="background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); padding: 32px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px;">${title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div style="padding: 40px 32px; background-color: #ffffff;">
|
||||
${messageHtml}
|
||||
|
||||
${buttonText && buttonUrl ? `
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="${buttonUrl}" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 15px; display: inline-block; box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2); transition: all 0.2s ease;">
|
||||
${buttonText}
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 32px; border-top: 1px solid #f1f5f9; padding-top: 20px;">
|
||||
Falls der Button nicht funktioniert, kopieren Sie bitte diesen Link in Ihren Browser:<br>
|
||||
<a href="${buttonUrl}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${buttonUrl}</a>
|
||||
</p>
|
||||
` : ''}
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid #f1f5f9; margin: 32px 0;">
|
||||
<p style="color: #94a3b8; font-size: 11px; text-align: center; margin: 0; line-height: 1.6;">
|
||||
Dies ist eine automatisch generierte E-Mail.<br>
|
||||
© ${new Date().getFullYear()} CASPOS Shop. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
104
shop/lib/actions/validation.ts
Normal file
104
shop/lib/actions/validation.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { WizardSelections, Product, Category } from '@/lib/types'
|
||||
|
||||
export function validateWizardSelections(
|
||||
selections: WizardSelections,
|
||||
dbProducts: Product[],
|
||||
dbCategories: Category[]
|
||||
): 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)
|
||||
)
|
||||
|
||||
if (cat.is_required && !hasSelection) {
|
||||
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
|
||||
}
|
||||
|
||||
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 => {
|
||||
const p = dbProducts.find(prod => prod.id === pId)
|
||||
if (p) selectedProds.push(p)
|
||||
})
|
||||
} else if (sel.productId) {
|
||||
const p = dbProducts.find(prod => prod.id === sel.productId)
|
||||
if (p) selectedProds.push(p)
|
||||
}
|
||||
|
||||
for (const prod of selectedProds) {
|
||||
// Validate selected modules
|
||||
for (const mId of sel.moduleIds) {
|
||||
const mod = prod.modules?.find(m => m.id === mId)
|
||||
if (!mod) continue
|
||||
|
||||
// Requirements check
|
||||
if (mod.requirements && mod.requirements.length > 0) {
|
||||
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
// Exclusions check
|
||||
if (mod.exclusions && mod.exclusions.length > 0) {
|
||||
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Product-level constraints validation ---
|
||||
const selectedProductIds: string[] = []
|
||||
Object.values(selections).forEach(s => {
|
||||
if (s.productIds && s.productIds.length > 0) {
|
||||
s.productIds.forEach(id => {
|
||||
if (id) selectedProductIds.push(id)
|
||||
})
|
||||
} else if (s.productId) {
|
||||
selectedProductIds.push(s.productId)
|
||||
}
|
||||
})
|
||||
|
||||
for (const prodId of selectedProductIds) {
|
||||
const prod = dbProducts.find(p => p.id === prodId)
|
||||
if (!prod) continue
|
||||
|
||||
// Product requirements check
|
||||
if (prod.requirements && prod.requirements.length > 0) {
|
||||
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
|
||||
if (!hasMetRequirement) {
|
||||
const reqNames = prod.requirements
|
||||
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
|
||||
.join(' oder ')
|
||||
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Product exclusions check
|
||||
if (prod.exclusions && prod.exclusions.length > 0) {
|
||||
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
|
||||
if (conflicting.length > 0) {
|
||||
const conflictingNames = conflicting
|
||||
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
|
||||
.join(', ')
|
||||
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user