Compare commits
293 Commits
a9e120e681
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cf32591f | ||
|
|
e78fe7c4b8 | ||
|
|
ec11ad8498 | ||
|
|
40b63b3fa9 | ||
|
|
facf481fdb | ||
|
|
5f29bb68a4 | ||
|
|
404e7cd314 | ||
|
|
2fc4e50ae1 | ||
|
|
694e8dfbc1 | ||
|
|
2a1897141e | ||
|
|
2b1bb77673 | ||
|
|
b692e6d4cf | ||
|
|
0c99da2eeb | ||
|
|
f68d67de43 | ||
|
|
073dc99860 | ||
|
|
af501d18c7 | ||
|
|
49a7a0768d | ||
|
|
e0d9c1d20e | ||
|
|
5ea0719225 | ||
|
|
3545b77803 | ||
|
|
fff40a68fe | ||
|
|
ea1906f9e4 | ||
|
|
9dbbbb8b31 | ||
|
|
409466af9b | ||
|
|
8f3d6e5569 | ||
|
|
8588eda3c9 | ||
|
|
8a4dbbaf14 | ||
|
|
037e6d749a | ||
|
|
1c8de6e895 | ||
|
|
4296a9106f | ||
|
|
c27cb858c1 | ||
|
|
a7816ba3c4 | ||
|
|
03087f4fbb | ||
|
|
f6760d30a4 | ||
|
|
682863ab26 | ||
|
|
9ecdc645e8 | ||
|
|
009b167cbd | ||
|
|
c1efc7916f | ||
|
|
d066316b67 | ||
|
|
b2a5f7cc64 | ||
|
|
9bde60553e | ||
|
|
16ca9611d7 | ||
|
|
5561dfa708 | ||
|
|
447f0a407c | ||
|
|
386527a35c | ||
|
|
982897a808 | ||
|
|
2f32d80e9e | ||
|
|
4598233cf4 | ||
|
|
edb5873e33 | ||
|
|
9593cbdb79 | ||
|
|
0404a1dd83 | ||
|
|
6eeb56d62a | ||
|
|
17e5b27dfc | ||
|
|
2f2cba1687 | ||
|
|
6748a3ffad | ||
|
|
18228fa8b4 | ||
|
|
2750898524 | ||
|
|
f40268992d | ||
|
|
b72e16706e | ||
|
|
9078b5631a | ||
|
|
1c67cd46bb | ||
|
|
5b9a0c609d | ||
|
|
f500a63e97 | ||
|
|
46699e8bf3 | ||
|
|
866c5522f2 | ||
|
|
1478c61ea8 | ||
|
|
7346b42ff3 | ||
|
|
bc02a6d000 | ||
|
|
883b607482 | ||
|
|
497d95d849 | ||
|
|
c3e81ee49e | ||
|
|
ddc7a03a0b | ||
|
|
e09c247f43 | ||
|
|
4b8dfd5b20 | ||
|
|
ea64ce9ef0 | ||
|
|
407484f69d | ||
|
|
73e046e959 | ||
|
|
3ba7c91e1f | ||
|
|
6177c0cde1 | ||
|
|
696528283a | ||
|
|
cab64028f6 | ||
|
|
8b698e42ec | ||
|
|
1c9ae2ec31 | ||
|
|
af261ce923 | ||
|
|
60b9bd89ef | ||
|
|
f9cb3c0685 | ||
|
|
2f63d716f2 | ||
|
|
1aa694a665 | ||
|
|
ed654f542c | ||
|
|
be3b0f615d | ||
|
|
169afb6538 | ||
|
|
b3ae720136 | ||
|
|
d02703af8b | ||
|
|
abd8f54ef7 | ||
|
|
c08237aa73 | ||
|
|
6a95896361 | ||
|
|
b40f8018f7 | ||
|
|
ec0405a901 | ||
|
|
83d9b796c6 | ||
|
|
a94f883ab6 | ||
|
|
ec264e2680 | ||
|
|
21e01b1b24 | ||
|
|
304d25f202 | ||
|
|
a2305319e1 | ||
|
|
70ed890702 | ||
|
|
b978a6bc64 | ||
|
|
a43d598f54 | ||
|
|
0e4495292d | ||
|
|
c6190b34a8 | ||
|
|
623dab192b | ||
|
|
c5285dc907 | ||
|
|
6cd5eb917c | ||
|
|
061147701e | ||
|
|
dfdb5d9024 | ||
|
|
66d5d8fc98 | ||
|
|
f130f24c86 | ||
|
|
06cba405f3 | ||
|
|
b8820f59b7 | ||
|
|
1c31918768 | ||
|
|
086a893e19 | ||
|
|
61adf76da5 | ||
|
|
2497063e82 | ||
|
|
0a60526800 | ||
|
|
fef1ccd0f5 | ||
|
|
3200784c51 | ||
|
|
acd8d6575c | ||
|
|
3db3606f17 | ||
|
|
f1877cf893 | ||
|
|
ac60995c1d | ||
|
|
07208fcad0 | ||
|
|
8b4fd5f02e | ||
|
|
86b63ce77e | ||
|
|
2b372be228 | ||
|
|
13f647409f | ||
|
|
5d3eed874c | ||
|
|
3a090b7664 | ||
|
|
e2d92a031f | ||
|
|
6b9023c871 | ||
|
|
f783a98392 | ||
|
|
d5593900fb | ||
|
|
92eb7454dc | ||
|
|
cb4787e595 | ||
|
|
3fb80e6404 | ||
|
|
a318a5e225 | ||
|
|
1b49348577 | ||
|
|
fb61f5f396 | ||
|
|
7119383139 | ||
|
|
fa93e060c2 | ||
|
|
be4e9204a3 | ||
|
|
2e30974aed | ||
|
|
c30505de05 | ||
|
|
5a2afd8600 | ||
|
|
72b906779c | ||
|
|
0b4755d438 | ||
|
|
d37a6b4348 | ||
|
|
21550f1389 | ||
|
|
43e6a92663 | ||
|
|
305671acc6 | ||
|
|
17d76f55e6 | ||
|
|
4e82146ab7 | ||
|
|
83a019785b | ||
|
|
f091b97ca0 | ||
|
|
6d6bb4dfe6 | ||
|
|
e59039dd4e | ||
|
|
f80866218b | ||
|
|
545b5e93bf | ||
|
|
e9117d34a0 | ||
|
|
d72830412e | ||
|
|
0a9997e98a | ||
|
|
0b268881a1 | ||
|
|
993c817dff | ||
|
|
4da386f693 | ||
|
|
c252fa11d0 | ||
|
|
1a7a765d72 | ||
|
|
ec1ad4d444 | ||
|
|
0679fdfdd7 | ||
|
|
0ecb16daff | ||
|
|
c8183abff7 | ||
|
|
0435984c82 | ||
|
|
27fe6c6eef | ||
|
|
bc6487bba4 | ||
|
|
d8b7076e7e | ||
|
|
9d888e6380 | ||
|
|
2f92213594 | ||
|
|
4fa2daf212 | ||
|
|
dc5465e289 | ||
|
|
99cbb11c5a | ||
|
|
a95afbf443 | ||
|
|
cd3fba2113 | ||
|
|
6821b4e8d9 | ||
|
|
a3c5a66d3f | ||
|
|
2038c4df3e | ||
|
|
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 |
18
.agents/agents.md
Normal file
18
.agents/agents.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Antigravity Agent Configuration (Project Rocky)
|
||||
|
||||
## Agent: Rocky_Manager
|
||||
- **Role**: Hauptkoordinator des Rocky-Projekts.
|
||||
- **Responsibility**: Verteilt Aufgaben an Spezialisten basierend auf den Skill-Anforderungen.
|
||||
|
||||
## Agent: Dokumenten_Spezialist
|
||||
- **Role**: Verantwortlich für Exporte und Berichte.
|
||||
- **Assigned_Skills**: [".agents/skills/pdf_skill.md"]
|
||||
|
||||
## Agent: Kommunikations_Spezialist
|
||||
- **Role**: Verantwortlich für Benachrichtigungen und Kundenkontakt.
|
||||
- **Assigned_Skills**: [".agents/skills/email_skill.md"]
|
||||
|
||||
## Agent: Compliance_Supervisor
|
||||
- **Role**: Prüft den geschriebenen Code auf Datenschutzfehler.
|
||||
- **Assigned_Skills**: [".agents/skills/dsgvo_skill.md"]
|
||||
- **Rule**: "Blockiere jeden Git-Commit, wenn der Code gegen die DSGVO-Vorgaben verstößt."
|
||||
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>"`.
|
||||
6
.agents/skills/dsgvo_skill.md
Normal file
6
.agents/skills/dsgvo_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: DSGVO & Datenschutz-Compliance (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Daten-Minimierung**: Spezialisten dürfen personenbezogene Daten (wie E-Mail-Adressen oder Klarnamen) niemals in Log-Dateien oder im Git-Verlauf speichern.
|
||||
2. **Verschlüsselung**: Daten im Rocky-Projekt müssen "at rest" und "in transit" (mindestens TLS 1.3) verschlüsselt werden.
|
||||
3. **Anonymisierung**: Für Testzwecke im Terminal-Subagent dürfen nur synthetische Dummy-Daten verwendet werden. Keine echten Kundendaten nutzen!
|
||||
6
.agents/skills/email_skill.md
Normal file
6
.agents/skills/email_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: E-Mail-Kommunikation (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Tonalität**: Professionell, höflich und im Corporate Design des Rocky-Projekts.
|
||||
2. **Sicherheit**: Sende niemals Passwörter, Token oder unverschlüsselte API-Keys per E-Mail.
|
||||
3. **Fallbacks**: Wenn ein E-Mail-Versand fehlschlägt, schreibe den Fehler sofort in die lokale Log-Datei `rocky_mail_errors.log` und benachrichtige den Manager-Agenten.
|
||||
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.
|
||||
7
.agents/skills/lucide-icons-guide/skill.md
Normal file
7
.agents/skills/lucide-icons-guide/skill.md
Normal file
@@ -0,0 +1,7 @@
|
||||
skill: lucide-icons-guide
|
||||
description: Integration, Auswahl und Optimierung von Lucide React Icons (lucide.dev) für Next.js 15 (RSC/Server Actions) und Tailwind CSS.
|
||||
rules:
|
||||
- Importiere Icons direkt aus `lucide-react` (z. B. `import { Package, ShieldCheck, ShoppingCart } from 'lucide-react'`).
|
||||
- Achte bei React Server Components (RSC) darauf, dass Icons performant gerendert und keine unnötigen Client-Side Bundles erzeugt werden.
|
||||
- Verwende konsistente Größen (`size={18}` oder Tailwind `w-5 h-5`) und Stile (z. B. `strokeWidth={1.75}`) passend zum B2B-Design.
|
||||
- Nutze dynamische Icon-Name-Renderings nur über eine typsichere Map/Registry, um Unused-Code-Elimination (Tree Shaking) zu gewährleisten.
|
||||
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`.
|
||||
6
.agents/skills/pdf_skill.md
Normal file
6
.agents/skills/pdf_skill.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Skill: PDF-Verarbeitung & Generierung (Rocky-Projekt)
|
||||
|
||||
## Regeln für Aufgaben:
|
||||
1. **Formatierung**: Nutze ausschließlich barrierefreie PDF/A-Formate für Rechnungen und Berichte im Rocky-Projekt.
|
||||
2. **Metadaten**: Jedes generierte PDF muss automatisch den Projektnamen `Rocky` und einen eindeutigen Zeitstempel im Datei-Header enthalten.
|
||||
3. **Validierung**: Vor dem Speichern muss der Agent prüfen, ob alle Textfelder innerhalb der Seitenränder liegen (kein Textumbruch-Fehler).
|
||||
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)
|
||||
@@ -50,7 +50,6 @@ jobs:
|
||||
docker builder prune -f
|
||||
working-directory: .
|
||||
|
||||
|
||||
# KORREKTUR: IP-Adresse exakt auf Ihren Proxmox-Staging-Container eingestellt
|
||||
- name: Deploy to Proxmox LXC via SSH
|
||||
uses: appleboy/ssh-action@v0.1.5
|
||||
@@ -60,6 +59,7 @@ jobs:
|
||||
key: ${{ secrets.PROXMOX_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/webshop/supabase/supabase-project
|
||||
# 1. Altes Image durch das neue ersetzen
|
||||
docker compose pull webshop
|
||||
# 2. Container im Hintergrund neu starten
|
||||
docker compose up -d webshop
|
||||
docker image prune -f
|
||||
|
||||
249
AsciiShaderBackground.astro
Normal file
249
AsciiShaderBackground.astro
Normal file
@@ -0,0 +1,249 @@
|
||||
---
|
||||
// AsciiShaderBackground.astro
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { className = "" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`ascii-shader-container ${className}`}>
|
||||
<canvas id="ascii-shader-canvas"></canvas>
|
||||
<div class="grid-overlay"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ascii-shader-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
background-color: oklch(0.145 0.01 148);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#ascii-shader-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 40px 40px;
|
||||
background-image:
|
||||
linear-gradient(to right, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// OKLCH Palette definition
|
||||
interface OKLCHColor {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// Palette defining color stops based on scroll progress
|
||||
const PALETTE = {
|
||||
yellow: { l: 0.88, c: 0.18, h: 96 },
|
||||
green: { l: 0.76, c: 0.15, h: 155 },
|
||||
blue: { l: 0.68, c: 0.18, h: 255 },
|
||||
purple: { l: 0.64, c: 0.18, h: 302 },
|
||||
pink: { l: 0.72, c: 0.20, h: 345 }
|
||||
};
|
||||
|
||||
// Helper for shortest angle interpolation in OKLCH hue space
|
||||
function interpolateHue(h1: number, h2: number, t: number): number {
|
||||
let diff = (h2 - h1) % 360;
|
||||
if (diff > 180) diff -= 360;
|
||||
if (diff < -180) diff += 360;
|
||||
return (h1 + diff * t + 360) % 360;
|
||||
}
|
||||
|
||||
// Interpolate two OKLCH colors
|
||||
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
||||
return {
|
||||
l: c1.l + (c2.l - c1.l) * t,
|
||||
c: c1.c + (c2.c - c1.c) * t,
|
||||
h: interpolateHue(c1.h, c2.h, t)
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate dynamic colors based on scroll progress (0.0 to 1.0)
|
||||
function getScrollColors(progress: number): { primary: OKLCHColor; secondary: OKLCHColor } {
|
||||
if (progress < 0.33) {
|
||||
// Hero to stage 1: Full spectrum (Pink -> Yellow)
|
||||
const t = progress / 0.33;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.pink, PALETTE.yellow, t),
|
||||
secondary: lerpOKLCH(PALETTE.purple, PALETTE.green, t)
|
||||
};
|
||||
} else if (progress < 0.66) {
|
||||
// Stage 1 to stage 2: Yellow/Cream to Green
|
||||
const t = (progress - 0.33) / 0.33;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.yellow, PALETTE.green, t),
|
||||
secondary: lerpOKLCH(PALETTE.green, PALETTE.blue, t)
|
||||
};
|
||||
} else {
|
||||
// Stage 2 to end: Green/Blue focus
|
||||
const t = (progress - 0.66) / 0.34;
|
||||
return {
|
||||
primary: lerpOKLCH(PALETTE.green, PALETTE.blue, t),
|
||||
secondary: lerpOKLCH(PALETTE.blue, PALETTE.purple, t)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AsciiShaderEngine {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
|
||||
private scrollProgress = 0;
|
||||
private targetScrollProgress = 0;
|
||||
private chars = ['/', '\\', '-', '|', '+', '='];
|
||||
private fontSize = 16;
|
||||
private cols = 0;
|
||||
private rows = 0;
|
||||
private animationFrameId = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('Could not get 2D context');
|
||||
this.ctx = context;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.resize();
|
||||
this.bindEvents();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private resize = (): void => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
|
||||
this.ctx.scale(dpr, dpr);
|
||||
|
||||
this.cols = Math.ceil(width / this.fontSize);
|
||||
this.rows = Math.ceil(height / this.fontSize);
|
||||
|
||||
if (this.mouse.x === 0 && this.mouse.y === 0) {
|
||||
this.mouse.x = width / 2;
|
||||
this.mouse.y = height / 2;
|
||||
this.mouse.targetX = width / 2;
|
||||
this.mouse.targetY = height / 2;
|
||||
}
|
||||
};
|
||||
|
||||
private bindEvents(): void {
|
||||
window.addEventListener('resize', this.resize);
|
||||
|
||||
window.addEventListener('pointermove', (e: PointerEvent) => {
|
||||
this.mouse.targetX = e.clientX;
|
||||
this.mouse.targetY = e.clientY;
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const maxScroll = Math.max(
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
1
|
||||
);
|
||||
this.targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
private render = (time: number = 0): void => {
|
||||
// Dampened mouse easing (lerp factor 0.05)
|
||||
this.mouse.x += (this.mouse.targetX - this.mouse.x) * 0.05;
|
||||
this.mouse.y += (this.mouse.targetY - this.mouse.y) * 0.05;
|
||||
|
||||
// Dampened scroll progress easing
|
||||
this.scrollProgress += (this.targetScrollProgress - this.scrollProgress) * 0.05;
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Get current OKLCH interpolated colors
|
||||
const { primary, secondary } = getScrollColors(this.scrollProgress);
|
||||
|
||||
this.ctx.font = `${this.fontSize}px monospace`;
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
|
||||
const timeSec = time * 0.001;
|
||||
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const x = c * this.fontSize + this.fontSize / 2;
|
||||
const y = r * this.fontSize + this.fontSize / 2;
|
||||
|
||||
// Vector field calculations based on distance to cursor & wave function
|
||||
const dx = x - this.mouse.x;
|
||||
const dy = y - this.mouse.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Dynamic angle field
|
||||
const angle = Math.atan2(dy, dx) + Math.sin(dist * 0.01 - timeSec) * 0.5;
|
||||
const charIndex = Math.floor(Math.abs(Math.sin(angle + dist * 0.005)) * this.chars.length) % this.chars.length;
|
||||
const char = this.chars[charIndex];
|
||||
|
||||
// Color blend based on vector field intensity
|
||||
const blendFactor = Math.min(Math.max(1 - dist / 400, 0), 1);
|
||||
const currentColor = lerpOKLCH(secondary, primary, blendFactor);
|
||||
|
||||
// Render character with OKLCH styling
|
||||
this.ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)})`;
|
||||
this.ctx.fillText(char, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.render);
|
||||
};
|
||||
|
||||
public destroy(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle handler for Astro / SPA transitions
|
||||
function initShader() {
|
||||
const canvas = document.getElementById('ascii-shader-canvas') as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
new AsciiShaderEngine(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initShader);
|
||||
} else {
|
||||
initShader();
|
||||
}
|
||||
|
||||
// Astro page swap support
|
||||
document.addEventListener('astro:page-load', initShader);
|
||||
</script>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
319
shop/app/actions/checkout.ts
Normal file
319
shop/app/actions/checkout.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
'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 { generateOrderEmailHtml, generateOrderEmailSubject } 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 emailTemplate = generateOrderEmailHtml({
|
||||
orderNumber: order.order_number,
|
||||
status: 'pending',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
items,
|
||||
taxRate,
|
||||
oneTimeNet,
|
||||
monthlyNet,
|
||||
});
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: mailSubject,
|
||||
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;
|
||||
lastLicenseDate?: string | null;
|
||||
}) {
|
||||
const { items, endCustomerId, customerProfile, lastLicenseDate } = 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' || dbUser?.role === 'verwaltung';
|
||||
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,
|
||||
type === 'purchase' ? lastLicenseDate : null
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName,
|
||||
license_number: item.licenseNumber
|
||||
}));
|
||||
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,
|
||||
last_license_date: type === 'purchase' ? lastLicenseDate : null
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default async function AdminCategoriesPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<LayoutGrid className="w-8 h-8 text-primary" />
|
||||
Kategorien
|
||||
</h2>
|
||||
|
||||
@@ -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,
|
||||
@@ -249,32 +245,32 @@ export default function CompanyCustomersPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||
<tr key={customer.id} className="hover:bg-slate-100 dark:hover:bg-white/10 transition-colors group">
|
||||
<td className="py-4">
|
||||
<div className="font-semibold text-slate-900 dark:text-white">
|
||||
{customer.company_name}
|
||||
</div>
|
||||
{(customer.first_name || customer.last_name) && (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<div className="text-xs text-slate-600 dark:text-slate-300 font-medium flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||
<span>{customer.first_name || ''} {customer.last_name || ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{customer.street ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<MapPin className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||
<span>
|
||||
{customer.street}, {customer.zip} {customer.city}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 dark:text-slate-600">–</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">–</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
{customer.email || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{customer.email || <span className="text-slate-400 dark:text-slate-500">–</span>}
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
{customer.vat_id || <span className="text-slate-400 dark:text-slate-600">–</span>}
|
||||
@@ -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,7 +14,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
|
||||
import { getCompanies, createCompany, updateCompany, deleteCompany, syncCompaniesFromLicServer, syncCustomersFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export default function CompaniesPage() {
|
||||
const [companies, setCompanies] = useState<any[]>([])
|
||||
@@ -32,6 +32,28 @@ export default function CompaniesPage() {
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null)
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('')
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true)
|
||||
setStatusMsg(null)
|
||||
try {
|
||||
const resCompanies = await syncCompaniesFromLicServer()
|
||||
const resCustomers = await syncCustomersFromLicServer()
|
||||
setStatusMsg(`${resCompanies.count} Partner und ${resCustomers.count} Kunden erfolgreich vom LicServer importiert.`)
|
||||
setStatusType('success')
|
||||
loadCompanies()
|
||||
} catch (err: any) {
|
||||
setStatusMsg(`Fehler beim Import: ${err.message || err}`)
|
||||
setStatusType('error')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -90,18 +112,7 @@ export default function CompaniesPage() {
|
||||
|
||||
|
||||
|
||||
const handleDelete = async (id: string, firmName: string) => {
|
||||
if (!confirm(`Firma "${firmName}" wirklich löschen? Alle zugewiesenen Benutzer werden getrennt.`)) return
|
||||
setDeletingId(id)
|
||||
try {
|
||||
await deleteCompany(id)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const filteredCompanies = companies.filter((c) => {
|
||||
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -123,14 +134,34 @@ export default function CompaniesPage() {
|
||||
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
variant="outline"
|
||||
className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white"
|
||||
>
|
||||
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
|
||||
Partner importieren
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Firmen-Tabelle */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
|
||||
<div className="relative mb-6 max-w-sm">
|
||||
@@ -166,25 +197,25 @@ export default function CompaniesPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-white/5">
|
||||
{filteredCompanies.map((company) => (
|
||||
<tr key={company.id} className="hover:bg-slate-50 dark:hover:bg-white/3 transition-colors">
|
||||
<tr key={company.id} className="hover:bg-slate-100 dark:hover:bg-white/10 transition-colors group">
|
||||
<td className="py-4 font-semibold text-slate-900 dark:text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
<Building2 className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
{company.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<span>
|
||||
{company.street ? `${company.street}, ${company.zip} ${company.city}` : '–'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 text-sm text-slate-700 dark:text-slate-300">
|
||||
<td className="py-4 text-sm text-slate-800 dark:text-slate-100 font-medium">
|
||||
{company.email ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<Mail className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<span>{company.email}</span>
|
||||
</div>
|
||||
) : '–'}
|
||||
@@ -214,11 +245,14 @@ export default function CompaniesPage() {
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={deletingId === company.id}
|
||||
onClick={() => handleDelete(company.id, company.name)}
|
||||
onClick={() => {
|
||||
setDeleteConfirmId(company.id)
|
||||
setDeleteConfirmName(company.name)
|
||||
}}
|
||||
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
title="Firma löschen"
|
||||
>
|
||||
@@ -329,6 +363,44 @@ export default function CompaniesPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog: Delete Confirmation */}
|
||||
<Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
|
||||
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Firma löschen</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||
Möchten Sie die Firma <strong>{deleteConfirmName}</strong> wirklich löschen? Alle zugewiesenen Benutzer werden getrennt. Dieser Schritt kann nicht rückgängig gemacht werden.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (deleteConfirmId) {
|
||||
setDeletingId(deleteConfirmId)
|
||||
const targetId = deleteConfirmId
|
||||
setDeleteConfirmId(null)
|
||||
try {
|
||||
await deleteCompany(targetId)
|
||||
loadCompanies()
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="bg-red-600 hover:bg-red-700 text-white font-semibold"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
124
shop/app/admin/einstellungen/branding/page.tsx
Normal file
124
shop/app/admin/einstellungen/branding/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, Palette, Save } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
|
||||
import type { BrandingSettings } from '@/lib/constants/branding';
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function BrandingPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [branding, setBranding] = useState<BrandingSettings>({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const { refreshBranding } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const brandRes = await getBrandingSettings();
|
||||
if (brandRes) setBranding(brandRes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await saveBrandingSettings(branding);
|
||||
if (res.success) {
|
||||
await refreshBranding();
|
||||
setMsgType('success');
|
||||
setMsg('Farbschema erfolgreich gespeichert.');
|
||||
} else {
|
||||
setMsgType('error');
|
||||
setMsg(res.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMsgType('error');
|
||||
setMsg(err.message || 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Palette className="w-8 h-8 text-violet-400" />
|
||||
Branding & Design
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Wählen Sie das primäre Farbschema und Akzente für den gesamten Webshop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Farben Speichern
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<ColorThemePicker
|
||||
colorScheme={branding.colorScheme}
|
||||
primaryColor={branding.primaryColor}
|
||||
accentColor={branding.accentColor}
|
||||
companyName={branding.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBranding({
|
||||
...branding,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
shop/app/admin/einstellungen/firmendaten/page.tsx
Normal file
231
shop/app/admin/einstellungen/firmendaten/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Building2, MapPin, Receipt, Save } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { getBrandingSettings, saveBrandingSettings } from '@/lib/actions/branding';
|
||||
import type { BrandingSettings } from '@/lib/constants/branding';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function FirmendatenPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [branding, setBranding] = useState<BrandingSettings>({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const brandRes = await getBrandingSettings();
|
||||
if (brandRes) setBranding(brandRes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await saveBrandingSettings(branding);
|
||||
if (res.success) {
|
||||
setMsgType('success');
|
||||
setMsg('Firmendaten erfolgreich gespeichert.');
|
||||
} else {
|
||||
setMsgType('error');
|
||||
setMsg(res.error || 'Fehler beim Speichern.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMsgType('error');
|
||||
setMsg(err.message || 'Fehler beim Speichern.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Building2 className="w-8 h-8 text-blue-400" />
|
||||
Firmendaten & Anschrift
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Verwaltung der eigenen Unternehmensanschrift für Rechnungen, Dokumente und Footereinträge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${msgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Firmenname *</Label>
|
||||
<Input
|
||||
value={branding.companyName}
|
||||
onChange={(e) => setBranding({ ...branding, companyName: e.target.value })}
|
||||
placeholder="z. B. Meine Firma GmbH"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Logo-URL (Optional)</Label>
|
||||
<Input
|
||||
value={branding.logoUrl || ''}
|
||||
onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })}
|
||||
placeholder="https://domain.de/logo.png oder /assets/logo.png"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-bold text-slate-300">Entwickler / Subline Beschriftung (Footer & Header)</Label>
|
||||
<Input
|
||||
value={branding.developerFooter || ''}
|
||||
onChange={(e) => setBranding({ ...branding, developerFooter: e.target.value })}
|
||||
placeholder="CASPOS Shop"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4 pt-3 border-t border-slate-800">
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Hauptanschrift
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Straße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.street}
|
||||
onChange={(e) => setBranding({ ...branding, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.zip}
|
||||
onChange={(e) => setBranding({ ...branding, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.city}
|
||||
onChange={(e) => setBranding({ ...branding, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branding.sameBillingAddress}
|
||||
onChange={(e) => setBranding({ ...branding, sameBillingAddress: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-slate-700 bg-slate-950 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Hauptanschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!branding.sameBillingAddress ? (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-slate-950/40 border border-slate-800">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-400">Rechnungsstraße & Nr.</Label>
|
||||
<Input
|
||||
value={branding.billingStreet}
|
||||
onChange={(e) => setBranding({ ...branding, billingStreet: e.target.value })}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">PLZ</Label>
|
||||
<Input
|
||||
value={branding.billingZip}
|
||||
onChange={(e) => setBranding({ ...branding, billingZip: e.target.value })}
|
||||
placeholder="54321"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-xs text-slate-400">Ort</Label>
|
||||
<Input
|
||||
value={branding.billingCity}
|
||||
onChange={(e) => setBranding({ ...branding, billingCity: e.target.value })}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-slate-950/80 border-slate-800 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-xl bg-slate-950/40 border border-slate-800/60 text-xs text-slate-500 italic">
|
||||
Verwendet automatisch die Hauptanschrift oben.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-slate-800">
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-blue-600 hover:bg-blue-500 text-white rounded-xl px-6 h-10 text-xs font-bold">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Save className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
shop/app/admin/einstellungen/licserver/page.tsx
Normal file
162
shop/app/admin/einstellungen/licserver/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
|
||||
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LicServerPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [licUrl, setLicUrl] = useState('');
|
||||
const [licKey, setLicKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [licSaving, setLicSaving] = useState(false);
|
||||
const [licTesting, setLicTesting] = useState(false);
|
||||
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [licMsg, setLicMsg] = useState('');
|
||||
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase.from('settings').select('licserver_base_url, licserver_api_key').eq('id', 'licserver').maybeSingle();
|
||||
if (data) {
|
||||
setLicUrl(data.licserver_base_url || '');
|
||||
setLicKey(data.licserver_api_key || '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<KeyRound className="w-8 h-8 text-violet-400" />
|
||||
CASPOS LicServer
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
REST API-Anbindung für die automatisierte Lizenzierung und Schlüsselgenerierung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{licStatus && (
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold border ${
|
||||
licStatus.ok
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||||
}`}>
|
||||
{licStatus.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
{licStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{licMsg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
licMsgType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}>
|
||||
{licMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-url" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
|
||||
</Label>
|
||||
<Input
|
||||
id="lic-url"
|
||||
value={licUrl}
|
||||
onChange={e => setLicUrl(e.target.value)}
|
||||
placeholder="http://192.168.178.174:9980"
|
||||
className="text-sm font-mono bg-slate-950/80 border-slate-800 text-white rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lic-key" className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="lic-key"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={licKey}
|
||||
onChange={e => setLicKey(e.target.value)}
|
||||
placeholder="Ihr X-Api-Key"
|
||||
className="text-sm font-mono pr-10 bg-slate-950/80 border-slate-800 text-white rounded-xl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-800">
|
||||
<Button
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicSaving(true);
|
||||
setLicMsg('');
|
||||
setLicStatus(null);
|
||||
const res = await saveLicServerConfig(licUrl, licKey);
|
||||
setLicMsgType(res.success ? 'success' : 'error');
|
||||
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
|
||||
setLicSaving(false);
|
||||
}}
|
||||
className="bg-violet-600 hover:bg-violet-500 text-white rounded-xl px-5 h-10 text-xs font-bold"
|
||||
>
|
||||
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
|
||||
Speichern
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={licSaving || licTesting}
|
||||
onClick={async () => {
|
||||
setLicTesting(true);
|
||||
setLicStatus(null);
|
||||
await saveLicServerConfig(licUrl, licKey);
|
||||
const result = await testLicServerConnection();
|
||||
setLicStatus(result);
|
||||
setLicTesting(false);
|
||||
}}
|
||||
className="border-slate-700 text-slate-300 hover:text-white rounded-xl px-5 h-10 text-xs font-bold"
|
||||
>
|
||||
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +1,126 @@
|
||||
/* Admin Settings with Database Import / Export functionality */
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Building2, Palette, KeyRound, Mail, Sliders, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const [demoActive, setDemoActive] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Firmendaten & Anschrift',
|
||||
description: 'Verwaltung der eigenen Firmenanschrift, Rechnungsadresse und Sublines für Dokumente.',
|
||||
href: '/admin/einstellungen/firmendaten',
|
||||
icon: Building2,
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/20',
|
||||
},
|
||||
{
|
||||
title: 'Branding & Farbschema',
|
||||
description: 'Anpassung von Farbschemata (Primary / Accent), Buttons und Themes für den Webshop.',
|
||||
href: '/admin/einstellungen/branding',
|
||||
icon: Palette,
|
||||
color: 'text-violet-400',
|
||||
bgColor: 'bg-violet-500/10 border-violet-500/20',
|
||||
},
|
||||
{
|
||||
title: 'CASPOS LicServer',
|
||||
description: 'REST API Konfiguration für den Lizenzserver & automatisierte Schlüsselgenerierung.',
|
||||
href: '/admin/einstellungen/licserver',
|
||||
icon: KeyRound,
|
||||
color: 'text-amber-400',
|
||||
bgColor: 'bg-amber-500/10 border-amber-500/20',
|
||||
},
|
||||
{
|
||||
title: 'SMTP E-Mail Server',
|
||||
description: 'Serverdaten & Authentifizierung für Bestellbestätigungen und Benachrichtigungen.',
|
||||
href: '/admin/settings',
|
||||
icon: Mail,
|
||||
color: 'text-sky-400',
|
||||
bgColor: 'bg-sky-500/10 border-sky-500/20',
|
||||
},
|
||||
{
|
||||
title: 'System & Backup',
|
||||
description: 'Demo-Banner Steuerung sowie ZIP-Export und Wiederherstellung der Datenbank.',
|
||||
href: '/admin/einstellungen/system',
|
||||
icon: Sliders,
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/10 border-emerald-500/20',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminSettingsOverview() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
}, []);
|
||||
|
||||
const toggleDemo = (checked: boolean) => {
|
||||
setDemoActive(checked);
|
||||
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
setStatusMsg('Export läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const res = await fetch('/api/admin/db-backup');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
setStatusMsg('Export erfolgreich abgeschlossen.');
|
||||
setStatusType('success');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Export: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
async function checkAccess() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const { data } = await supabase.from('users').select('role').eq('id', user.id).single();
|
||||
if (!data || data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkAccess();
|
||||
}, [router]);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setStatusMsg('Import läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const res = await fetch('/api/admin/db-backup', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
||||
|
||||
setStatusMsg('Datenbank erfolgreich importiert!');
|
||||
setStatusType('success');
|
||||
setSelectedFile(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Import: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto text-slate-900 dark:text-white space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Admin Einstellungen</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
Verwalten Sie globale Shopeinstellungen.
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white">Systemeinstellungen</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Wählen Sie eine Kategorie aus, um Konfigurationen, Server-Anbindungen oder Branding anzupassen.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Allgemeine Einstellungen */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
Allgemeine Einstellungen
|
||||
</h2>
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-lg border border-slate-100 dark:border-white/5">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-slate-900 dark:text-white">Demo-Warnmeldungen</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Schaltet gelbe Banner ein oder aus.</p>
|
||||
</div>
|
||||
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datenimport & -export */}
|
||||
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
Datenimport & -export
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
|
||||
Sichern Sie Ihre gesamte Instanz oder stellen Sie Daten wieder her.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-4 rounded-lg text-sm border ${
|
||||
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
|
||||
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Export Card */}
|
||||
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Download className="w-4 h-4 text-primary" />
|
||||
Daten exportieren
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-primary hover:bg-primary/90 text-white"
|
||||
>
|
||||
{exporting ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
|
||||
) : (
|
||||
<><Download className="w-4 h-4 mr-2" /> ZIP-Backup herunterladen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Import Card */}
|
||||
<div className="p-5 bg-slate-50 dark:bg-white/5 border border-slate-100 dark:border-white/5 rounded-xl flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Upload className="w-4 h-4 text-amber-500" />
|
||||
Daten importieren
|
||||
</h3>
|
||||
<div className="flex items-start gap-2 bg-amber-500/10 border border-amber-500/20 p-2.5 rounded-lg text-amber-600 dark:text-amber-400 text-[11px] leading-snug">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Warnung:</strong> Der Import überschreibt alle Daten dieser Instanz unwiderruflich!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
disabled={exporting || importing}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
{selectedFile ? (
|
||||
<span className="text-primary font-semibold">{selectedFile.name}</span>
|
||||
) : (
|
||||
"ZIP-Datei auswählen oder hierher ziehen"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 dark:border-white/10"
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{menuItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={idx} href={item.href}>
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md h-full flex flex-col justify-between space-y-4 hover:border-slate-700 transition-all shadow-md group cursor-pointer"
|
||||
>
|
||||
{importing ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Import läuft...</>
|
||||
) : (
|
||||
<><Upload className="w-4 h-4 mr-2" /> ZIP-Backup einspielen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className={`w-12 h-12 rounded-xl border flex items-center justify-center ${item.bgColor}`}>
|
||||
<Icon className={`w-6 h-6 ${item.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs font-bold text-slate-300 group-hover:text-primary pt-3 border-t border-slate-800/80 gap-1.5 transition-colors">
|
||||
<span>Einstellungen öffnen</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
226
shop/app/admin/einstellungen/system/page.tsx
Normal file
226
shop/app/admin/einstellungen/system/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Download, Upload, Loader2, Sliders, ShieldAlert, FileArchive } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function SystemPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [demoActive, setDemoActive] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState('');
|
||||
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
|
||||
setDemoActive(state);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, [router]);
|
||||
|
||||
const toggleDemo = (checked: boolean) => {
|
||||
setDemoActive(checked);
|
||||
localStorage.setItem('demo_banner_disabled', (!checked).toString());
|
||||
window.dispatchEvent(new Event('storage_demo_changed'));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
setStatusMsg('Export läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const res = await fetch('/api/admin/db-backup');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `webshop_backup_${new Date().toISOString().split('T')[0]}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
setStatusMsg('Export erfolgreich abgeschlossen.');
|
||||
setStatusType('success');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Export: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) return;
|
||||
if (!confirm('ACHTUNG: Dies löscht und überschreibt alle aktuellen Datenbankinhalte in dieser Instanz. Möchten Sie wirklich fortfahren?')) {
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setStatusMsg('Import läuft...');
|
||||
setStatusType('info');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
const res = await fetch('/api/admin/db-backup', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
||||
|
||||
setStatusMsg('Datenbank erfolgreich importiert!');
|
||||
setStatusType('success');
|
||||
setSelectedFile(null);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setStatusMsg(`Fehler beim Import: ${err.message}`);
|
||||
setStatusType('error');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-white flex items-center gap-3">
|
||||
<Sliders className="w-8 h-8 text-amber-400" />
|
||||
System & Backup
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Verwaltung von System-Bannern, Datenbank-Sicherungen und Wiederherstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div className={`p-3.5 rounded-xl text-xs font-medium border ${
|
||||
statusType === 'success' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' :
|
||||
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
||||
'bg-sky-500/10 border-sky-500/20 text-sky-400'
|
||||
}`}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-sky-500/10 border border-sky-500/20 text-sky-400 flex items-center justify-center">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">System Banner</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Gelbe Demo-Warnmeldungen im gesamten Shop aktivieren oder stummschalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-slate-800/80">
|
||||
<span className="text-xs font-semibold text-slate-300">Banner aktiv</span>
|
||||
<Switch checked={demoActive} onCheckedChange={toggleDemo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-2xl bg-slate-900/60 border border-slate-800 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||
<Download className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">DB Backup</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Lädt alle Produkte, Firmen, Lizenzen & Einstellungen als ZIP-Archiv herunter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || importing}
|
||||
className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold"
|
||||
>
|
||||
{exporting ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Export läuft...</>
|
||||
) : (
|
||||
<><Download className="w-4 h-4 mr-2" /> ZIP Export</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 p-5 rounded-2xl bg-slate-900/60 border border-slate-800 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||
<Upload className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-400 bg-amber-500/10 px-2.5 py-1 rounded-full border border-amber-500/20 flex items-center gap-1">
|
||||
<ShieldAlert className="w-3 h-3" /> Überschreibt DB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Daten Wiederherstellung</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Spielen Sie eine gesicherte ZIP-Sicherungsdatei ein. Warnung: Überschreibt aktuelle Datenbankinhalte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 pt-2">
|
||||
<div className="relative border border-dashed border-slate-700/80 rounded-xl p-3 hover:bg-slate-800/50 transition cursor-pointer flex items-center justify-center text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
disabled={exporting || importing}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-300 truncate px-2 flex items-center gap-1.5">
|
||||
<FileArchive className="w-4 h-4 text-amber-400 shrink-0" />
|
||||
{selectedFile ? selectedFile.name : 'ZIP-Datei auswählen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile || exporting || importing}
|
||||
variant="outline"
|
||||
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 rounded-xl h-10 text-xs font-bold"
|
||||
>
|
||||
{importing ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Importiert...</>
|
||||
) : (
|
||||
<><Upload className="w-4 h-4 mr-2" /> ZIP Einspielen</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,68 @@ 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/firmendaten">
|
||||
<Building2 className="w-5 h-5" />
|
||||
Firmendaten
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/einstellungen/branding">
|
||||
<Sparkles className="w-5 h-5 text-violet-400" />
|
||||
Branding & Design
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/einstellungen/licserver">
|
||||
<Wrench className="w-5 h-5 text-amber-400" />
|
||||
LicServer
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/settings">
|
||||
<Settings className="w-5 h-5 text-sky-400" />
|
||||
SMTP Server
|
||||
</AdminNavLink>
|
||||
<AdminNavLink href="/admin/einstellungen/system">
|
||||
<Database className="w-5 h-5 text-emerald-400" />
|
||||
System & Backup
|
||||
</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">
|
||||
|
||||
@@ -7,8 +7,8 @@ export default async function AdminOrdersPage() {
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gradient">Bestellungen</h1>
|
||||
<p className="text-slate-400">Verwalten Sie alle Kundenbestellungen und greifen Sie auf generierte Angebotsbestätigungen zu.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white">Anfragen & Bestellungen</h1>
|
||||
<p className="text-slate-400">Verwalten Sie alle Anfragen, prüfen Sie Details und greifen Sie auf generierte Anfragebestätigungen zu.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default async function AdminOrdersPage() {
|
||||
|
||||
async function OrdersData() {
|
||||
const adminDb = createAdminClient()
|
||||
|
||||
|
||||
const { data: orders, error } = await adminDb
|
||||
.from('orders')
|
||||
.select('*')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Package, ShoppingCart, Users, TrendingUp } from 'lucide-react'
|
||||
import { AdminRecentOrders } from '@/components/admin/recent-orders'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ─── KPI-Daten aus DB ────────────────────────────────────────────────────────
|
||||
async function getKpis() {
|
||||
@@ -40,55 +41,63 @@ export default async function AdminDashboard() {
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient">Dashboard</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white">Dashboard</h2>
|
||||
|
||||
{/* KPI Cards – echte DB-Daten */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Umsatz aktueller Monat</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalRevenue)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Nur fertige Bestellungen</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||
<p className="text-xs text-slate-400">Gesamt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/orders" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Bestellungen</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{orderCount}</div>
|
||||
<p className="text-xs text-slate-400">Gesamt</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/products" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Produkte</CardTitle>
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{productCount}</div>
|
||||
<p className="text-xs text-slate-400">Im Katalog</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Card className="glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||
<p className="text-xs text-slate-400">Registrierte User</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href="/admin/users" className="block transition-transform duration-300 hover:scale-[1.02]">
|
||||
<Card className="glass-dark border-white/5 cursor-pointer hover:border-primary/40 hover:bg-white/[0.07] transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-300">Kunden</CardTitle>
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{userCount}</div>
|
||||
<p className="text-xs text-slate-400">Registrierte User</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Letzte Bestellungen – Client Component mit 30s-Polling */}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async function AdminProductsPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-primary" />
|
||||
Produkte & Module
|
||||
</h2>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
// Admin Settings page – view & edit SMTP configuration and send test email
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Loader2, Mail, Server, ShieldCheck, Key, Send, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface Settings {
|
||||
host: string;
|
||||
@@ -11,23 +17,63 @@ interface Settings {
|
||||
pass: string;
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 15 },
|
||||
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [msgType, setMsgType] = useState<'success' | 'error' | ''>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Load current settings
|
||||
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() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const [userRes, smtpRes] = await Promise.all([
|
||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
||||
fetch('/api/admin/smtp-settings').then(res => res.json())
|
||||
]);
|
||||
|
||||
if (!userRes.data || userRes.data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (smtpRes.settings) {
|
||||
setSettings(smtpRes.settings as Settings);
|
||||
} else {
|
||||
setMessage('Fehler beim Laden der SMTP-Einstellungen.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch (err) {
|
||||
setMessage('Fehler beim Laden der Einstellungen.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAccessAndLoad();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
@@ -41,110 +87,195 @@ export default function SettingsPage() {
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMessage('');
|
||||
const res = await fetch('/api/admin/smtp-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) setMessage('Settings saved');
|
||||
else setMessage(data.error || 'Error saving settings');
|
||||
try {
|
||||
const res = await fetch('/api/admin/smtp-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage('SMTP-Einstellungen erfolgreich gespeichert.');
|
||||
setMsgType('success');
|
||||
} else {
|
||||
setMessage(data.error || 'Fehler beim Speichern.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch {
|
||||
setMessage('Verbindungsfehler beim Speichern.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
setTesting(true);
|
||||
setMessage('');
|
||||
const testPayload = {
|
||||
to: settings?.user || '', // send to the configured user address
|
||||
subject: 'Test Email from Webshop Admin',
|
||||
text: 'This is a test email to verify SMTP configuration.',
|
||||
};
|
||||
const res = await fetch('/api/admin/send-test-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) setMessage('Test email sent successfully');
|
||||
else setMessage(data.error || 'Failed to send test email');
|
||||
try {
|
||||
const testPayload = {
|
||||
to: settings?.user || '',
|
||||
subject: 'Test-E-Mail aus dem CASPOS Webshop Admin',
|
||||
text: 'Dies ist eine automatische Test-E-Mail zur Bestätigung der SMTP-Konfiguration.',
|
||||
};
|
||||
const res = await fetch('/api/admin/send-test-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setMessage('Test-E-Mail erfolgreich versendet.');
|
||||
setMsgType('success');
|
||||
} else {
|
||||
setMessage(data.error || 'Versand der Test-E-Mail fehlgeschlagen.');
|
||||
setMsgType('error');
|
||||
}
|
||||
} catch {
|
||||
setMessage('Fehler beim Senden der Test-E-Mail.');
|
||||
setMsgType('error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8">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">
|
||||
<h1 className="text-2xl font-bold mb-6">SMTP‑Einstellungen</h1>
|
||||
{message && <p className="mb-4 text-yellow-300">{message}</p>}
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<label className="flex flex-col">
|
||||
Host
|
||||
<input
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
value={settings?.host ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Port
|
||||
<input
|
||||
name="port"
|
||||
type="number"
|
||||
required
|
||||
value={settings?.port ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center space-x-2">
|
||||
<input
|
||||
name="secure"
|
||||
type="checkbox"
|
||||
checked={settings?.secure ?? false}
|
||||
onChange={handleChange}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>SSL / TLS (secure)</span>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Benutzer (SMTP‑User)
|
||||
<input
|
||||
name="user"
|
||||
type="email"
|
||||
required
|
||||
value={settings?.user ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col">
|
||||
Passwort
|
||||
<input
|
||||
name="pass"
|
||||
type="password"
|
||||
required
|
||||
value={settings?.pass ?? ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 p-2 rounded bg-black/20 border border-white/20"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex space-x-4 mt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary rounded hover:bg-primary/80 transition"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestEmail}
|
||||
className="px-4 py-2 bg-green-600 rounded hover:bg-green-500 transition"
|
||||
>
|
||||
Test‑Mail senden
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 max-w-5xl mx-auto text-slate-900 dark:text-white space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Mail className="w-8 h-8 text-sky-400" />
|
||||
SMTP-Einstellungen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Konfigurieren Sie den E-Mail-Server für transaktionale Benachrichtigungen und Bestellbestätigungen.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Dynamic Status Message */}
|
||||
{message && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
className={`p-3.5 rounded-xl text-xs font-semibold border flex items-center gap-2 ${
|
||||
msgType === 'success'
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-destructive/10 border-destructive/20 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{msgType === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
|
||||
{message}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Bento Grid layout for SMTP */}
|
||||
<form onSubmit={handleSave}>
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Bento Box 1: Server Connection Details (2 Spalten) */}
|
||||
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
|
||||
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>Server & Serververbindung</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-2 space-y-1.5">
|
||||
<Label htmlFor="host" className="text-xs font-bold text-slate-300">SMTP Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
name="host"
|
||||
type="text"
|
||||
required
|
||||
value={settings?.host ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="smtp.beispiel.de"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="port" className="text-xs font-bold text-slate-300">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
name="port"
|
||||
type="number"
|
||||
required
|
||||
value={settings?.port ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="587"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer p-3 rounded-xl bg-slate-950/50 border border-slate-800/80 hover:bg-slate-800/40 transition">
|
||||
<input
|
||||
name="secure"
|
||||
type="checkbox"
|
||||
checked={settings?.secure ?? false}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded text-sky-500 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-slate-200">SSL / TLS Verschlüsselung aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Box 2: Auth Credentials (1 Spalte) */}
|
||||
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sky-400 font-bold text-sm border-b border-slate-800 pb-3">
|
||||
<Key className="w-4 h-4" />
|
||||
<span>Zugangsdaten</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="user" className="text-xs font-bold text-slate-300">SMTP Benutzer</Label>
|
||||
<Input
|
||||
id="user"
|
||||
name="user"
|
||||
type="email"
|
||||
required
|
||||
value={settings?.user ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="absender@beispiel.de"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pass" className="text-xs font-bold text-slate-300">Passwort</Label>
|
||||
<Input
|
||||
id="pass"
|
||||
name="pass"
|
||||
type="password"
|
||||
required
|
||||
value={settings?.pass ?? ''}
|
||||
onChange={handleChange}
|
||||
placeholder="••••••••••••"
|
||||
className="bg-slate-950/80 border-slate-800 text-white rounded-xl font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit & Test Buttons */}
|
||||
<div className="flex flex-col gap-2 pt-4 border-t border-slate-800">
|
||||
<Button type="submit" disabled={saving || testing} className="w-full bg-sky-600 hover:bg-sky-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{saving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Speichert...</> : 'Konfiguration speichern'}
|
||||
</Button>
|
||||
<Button type="button" onClick={handleTestEmail} disabled={saving || testing} variant="outline" className="w-full border-slate-700 text-slate-300 hover:text-white rounded-xl h-10 text-xs font-bold">
|
||||
{testing ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Sendet...</> : <><Send className="w-3.5 h-3.5 mr-2 text-emerald-400" /> Test-Mail senden</>}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getDatabaseIntegrity,
|
||||
repairDatabaseSchema,
|
||||
optimizeDatabaseIndices
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
getDatabaseIntegrity,
|
||||
repairDatabaseSchema,
|
||||
optimizeDatabaseIndices
|
||||
} from "@/lib/actions/admin";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, RefreshCw, Wrench, Database, AlertTriangle, CheckCircle2, XCircle, Terminal, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2, RefreshCw, Wrench, Database, CheckCircle2, XCircle, Terminal } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface TableStatus {
|
||||
exists: boolean;
|
||||
@@ -18,22 +19,32 @@ interface TableStatus {
|
||||
}
|
||||
|
||||
interface IntegrityData {
|
||||
tables: {
|
||||
profiles: TableStatus;
|
||||
end_customers: TableStatus;
|
||||
pos_modules: TableStatus;
|
||||
licenses: TableStatus;
|
||||
};
|
||||
tables: Record<string, TableStatus>;
|
||||
errors: {
|
||||
foreign_keys: number;
|
||||
};
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 }
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 15 },
|
||||
visible: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 80, damping: 15 } }
|
||||
};
|
||||
|
||||
export default function AdminToolsPage() {
|
||||
const [integrity, setIntegrity] = useState<IntegrityData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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,196 +99,146 @@ export default function AdminToolsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
async function checkAccess() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const [userRes] = await Promise.all([
|
||||
supabase.from('users').select('role').eq('id', user.id).single(),
|
||||
loadStatus(true)
|
||||
]);
|
||||
|
||||
if (!userRes.data || userRes.data.role === 'verwaltung') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Laden von Admin Tools:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
checkAccess();
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-white flex justify-center items-center"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-blue-600/10 blur-[120px] rounded-full -z-10 opacity-30" />
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
||||
<p className="text-slate-200 text-lg font-medium">
|
||||
{activeAction === "repair"
|
||||
? "Schema wird repariert..."
|
||||
: activeAction === "optimize"
|
||||
? "Datenbank wird indexiert..."
|
||||
: "Verbindung zur Datenbank wird aufgebaut..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6 max-w-7xl mx-auto text-slate-900 dark:text-white space-y-6">
|
||||
{/* Header */}
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Database className="w-8 h-8 text-primary" />
|
||||
Datenbank Cockpit & Tools
|
||||
</h1>
|
||||
<p className="text-slate-400 text-xs">
|
||||
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Database className="w-8 h-8 text-primary" />
|
||||
Admin Datenbank-Cockpit
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Verwalten und reparieren Sie Systemtabellen, Sicherheitsrichtlinien (RLS) und Datenbank-Indizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-blue-400" />
|
||||
Integritätsscan
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
{/* Bento Grid layout */}
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Bento Card 1: Integritätsscan */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-blue-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Integritätsscan</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Prüft die physische Existenz der Tabellen und zählt deren Zeilenanzahl.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={() => loadStatus()}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
Status aktualisieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-emerald-400" />
|
||||
Schema reparieren
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert die RLS-Rechte.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={handleRepair}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
Schema reparieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass-dark border-white/10 flex flex-col justify-between">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-amber-400" />
|
||||
Index optimieren
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Baut korrupte Suchindizes im Schema public mittels REINDEX neu auf.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={handleOptimize}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
|
||||
>
|
||||
Index optimieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabellenstatus */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-xl">Tabellen-Status & Diagnose</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{integrity?.tables ? (
|
||||
Object.entries(integrity.tables).map(([name, status]) => (
|
||||
<div
|
||||
key={name}
|
||||
className="p-4 rounded-xl border bg-white/5 border-white/10 flex flex-col justify-between gap-2"
|
||||
>
|
||||
<span className="font-semibold text-slate-300 text-sm capitalize">
|
||||
{name.replace("_", " ")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-500">Zeilen: {status.count}</span>
|
||||
{status.exists ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" /> OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
|
||||
<XCircle className="w-3.5 h-3.5" /> Fehlt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-4 py-8 text-center text-slate-500 text-sm">
|
||||
Lade Diagnosedaten...
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => loadStatus()} disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === null ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüft...</> : 'Status Scannen'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Foreign Key Errors */}
|
||||
{integrity && integrity.errors.foreign_keys > 0 && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/20 flex gap-3 items-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-red-200">Fremdschlüssel-Fehler gefunden!</p>
|
||||
<p className="text-red-300/80 text-sm">
|
||||
Es wurden {integrity.errors.foreign_keys} verwaiste Lizenzen ohne zugeordneten Endkunden in der Tabelle `licenses` detektiert.
|
||||
</p>
|
||||
{/* Bento Card 2: Schema Reparieren */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-emerald-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<Wrench className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Schema Reparieren</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Erstellt fehlende Kerntabellen automatisch neu und konfiguriert RLS-Rechte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleRepair} disabled={isLoading} className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === 'repair' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Repariert...</> : 'Schema Reparieren'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Card 3: Index Optimieren */}
|
||||
<motion.div variants={itemVariants} whileHover={{ y: -3 }} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md flex flex-col justify-between space-y-4 hover:border-amber-500/30 transition-all duration-300 shadow-md">
|
||||
<div className="space-y-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-base text-white">Index Optimieren</h3>
|
||||
<p className="text-xs text-slate-400 mt-1 leading-relaxed">
|
||||
Baut Suchindizes im Schema public mittels REINDEX neu auf für maximale Performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleOptimize} disabled={isLoading} className="w-full bg-amber-600 hover:bg-amber-500 text-white rounded-xl h-10 text-xs font-bold">
|
||||
{isLoading && activeAction === 'optimize' ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Indexiert...</> : 'Index Optimieren'}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Bento Card 4: Tabellen-Diagnose (2 Spalten) */}
|
||||
<motion.div variants={itemVariants} className="md:col-span-2 p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-4 shadow-md">
|
||||
<h3 className="font-bold text-base text-white border-b border-slate-800 pb-3">Tabellen-Status & Diagnose</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{integrity?.tables ? (
|
||||
Object.entries(integrity.tables).map(([name, status]) => (
|
||||
<div key={name} className="p-3 rounded-xl border bg-slate-950/60 border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-bold text-xs text-slate-200 capitalize block">{name.replace("_", " ")}</span>
|
||||
<span className="text-[10px] text-slate-400">Zeilen: {status.count}</span>
|
||||
</div>
|
||||
{status.exists ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 text-[10px] py-0 px-1.5">OK</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 text-[10px] py-0 px-1.5">Fehlt</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 col-span-3">Keine Diagnose-Daten geladen.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Console / Output logs */}
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
Diagnose-Konsole
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLogs([])}
|
||||
className="text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
Konsole leeren
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="font-mono text-xs bg-black/50 p-4 rounded-xl border border-white/5 h-64 overflow-y-auto space-y-1.5 scrollbar-thin">
|
||||
{/* Bento Card 5: Live Konsole / Logs (1 Spalte) */}
|
||||
<motion.div variants={itemVariants} className="p-6 rounded-2xl bg-slate-900/60 border border-slate-800 backdrop-blur-md space-y-3 shadow-md flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-base text-white flex items-center gap-2 border-b border-slate-800 pb-3">
|
||||
<Terminal className="w-4 h-4 text-sky-400" /> System-Konsole
|
||||
</h3>
|
||||
<div className="bg-slate-950 p-3 rounded-xl border border-slate-800 font-mono text-[11px] text-slate-300 h-44 overflow-y-auto space-y-1 subpixel-antialiased">
|
||||
{logs.length === 0 ? (
|
||||
<span className="text-slate-600 italic">Konsole bereit. Führen Sie einen Scan aus.</span>
|
||||
<span className="text-slate-600 italic">Warte auf Aktionen...</span>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className={log.includes("FEHLER") ? "text-red-400" : log.includes("Erfolgreich") ? "text-emerald-400" : "text-slate-300"}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
logs.map((log, i) => <div key={i} className="leading-tight">{log}</div>)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function AdminUsersPage() {
|
||||
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-primary" />
|
||||
Benutzerverwaltung
|
||||
</h2>
|
||||
|
||||
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-white 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 || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
474
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
474
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
'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>
|
||||
|
||||
{/* Verknüpfte monatliche Gebühr */}
|
||||
<div className="space-y-1 mt-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Verknüpfte Gebühr:</span>
|
||||
<select
|
||||
value={prod.linked_fee_product_id || ''}
|
||||
onChange={async (e) => {
|
||||
const val = e.target.value || null
|
||||
setProducts(products.map(p => p.id === prod.id ? { ...p, linked_fee_product_id: val } : p))
|
||||
await updateProduct(prod.id, { linked_fee_product_id: val }, prod.modules || [])
|
||||
}}
|
||||
className="w-full bg-slate-950 border border-white/10 text-[10px] text-slate-300 focus:ring-0 cursor-pointer rounded px-2 py-1 outline-none"
|
||||
>
|
||||
<option value="">Keine Gebühr verknüpft</option>
|
||||
{products
|
||||
.filter(p => p.billing_interval === 'monthly' && p.id !== prod.id)
|
||||
.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(p.base_price)} / mtl.)
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Modul hinzufügen Popover */}
|
||||
<div className="mt-3 pt-2 border-t border-white/5">
|
||||
<ModulePopover
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,8 @@ export async function GET() {
|
||||
'orders',
|
||||
'profiles',
|
||||
'users',
|
||||
'settings'
|
||||
'settings',
|
||||
'branding_settings'
|
||||
];
|
||||
|
||||
const backupData: Record<string, any[]> = {};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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,
|
||||
@@ -7,42 +11,117 @@ export async function GET(
|
||||
) {
|
||||
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();
|
||||
|
||||
// 1. Get the order from database
|
||||
// 2. Get the order from database
|
||||
const { data: order, error } = await adminDb
|
||||
.from("orders")
|
||||
.select("pdf_url, order_number")
|
||||
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !order || !order.pdf_url) {
|
||||
return new NextResponse("PDF not found", { status: 404 });
|
||||
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 });
|
||||
}
|
||||
|
||||
// 2. Determine file name in storage bucket
|
||||
const storageFileName = `ab_${id}.pdf`;
|
||||
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
||||
|
||||
// 3. Download file from Supabase storage
|
||||
const { data: fileData, error: downloadError } = await adminDb
|
||||
.storage
|
||||
.from("invoices")
|
||||
.download(storageFileName);
|
||||
// 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) {
|
||||
console.error("Failed to download PDF from storage:", downloadError);
|
||||
return new NextResponse("Error downloading file from storage", { status: 500 });
|
||||
if (!downloadError && fileData) {
|
||||
fileBuffer = await fileData.arrayBuffer();
|
||||
}
|
||||
} catch (storageErr) {
|
||||
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Return as attachment
|
||||
const buffer = await fileData.arrayBuffer();
|
||||
// 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, {
|
||||
order: order,
|
||||
orderSnapshot: order.order_data,
|
||||
customer: order.customer_data,
|
||||
})
|
||||
);
|
||||
|
||||
// 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-Disposition", `attachment; filename="${downloadFileName}"`);
|
||||
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(buffer, {
|
||||
return new NextResponse(fileBuffer as any, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
|
||||
@@ -43,7 +43,20 @@ export async function POST(request: Request) {
|
||||
|
||||
const { to, subject, text, html } = await request.json();
|
||||
try {
|
||||
const info = await sendMail({ to, subject, text, html });
|
||||
const { data: brandData } = await supabase.from('settings').select('primary_color').eq('id', 'branding').maybeSingle();
|
||||
const primaryColor = brandData?.primary_color || '#2563eb';
|
||||
|
||||
const testHtml = html || `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
|
||||
<h2 style="color: ${primaryColor}; margin-bottom: 16px;">Test-E-Mail</h2>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${text || 'Dies ist eine automatische Test-E-Mail.'}</p>
|
||||
<div style="margin-top: 24px; padding: 12px; background-color: #f8fafc; border-left: 4px solid ${primaryColor}; font-size: 14px; color: #334155;">
|
||||
SMTP-Konfiguration & Branding-Farbe erfolgreich verifiziert.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const info = await sendMail({ to, subject, text, html: testHtml });
|
||||
return NextResponse.json({ message: 'Email sent', info });
|
||||
} catch (err) {
|
||||
console.error('Mail error', err);
|
||||
|
||||
@@ -1,96 +1,76 @@
|
||||
// API route for getting and updating SMTP settings (protected by admin check)
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { cookies } from 'next/headers';
|
||||
import { verifyAdmin } from '@/lib/actions/auth';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
await verifyAdmin();
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabase = createServerClient(
|
||||
supabaseUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
|
||||
}
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
try {
|
||||
let client: any;
|
||||
try {
|
||||
client = createAdminClient();
|
||||
} catch {
|
||||
client = await createClient();
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !authUser) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
}
|
||||
const { data, error } = await client
|
||||
.from('settings')
|
||||
.select('*')
|
||||
.eq('id', 'smtp')
|
||||
.maybeSingle();
|
||||
|
||||
const { data, error } = await supabase.from('settings').select('*').maybeSingle();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
if (error) {
|
||||
console.error('Error fetching SMTP settings:', error);
|
||||
return NextResponse.json({
|
||||
settings: { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('SMTP settings GET error:', err);
|
||||
return NextResponse.json({
|
||||
settings: { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
settings: data || { host: '', port: 587, secure: false, user: '', pass: '' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || '';
|
||||
const supabase = createServerClient(
|
||||
supabaseUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? '',
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Kann in einer API-Route ignoriert werden, wenn nur gelesen wird
|
||||
}
|
||||
},
|
||||
},
|
||||
cookieOptions: {
|
||||
name: "webshop-auth-token",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !authUser) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
const { data: user } = await supabase.from('users').select('role').eq('id', authUser.id).single();
|
||||
if (!user || user.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 });
|
||||
try {
|
||||
await verifyAdmin();
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json(); // expect {host, port, secure, user, pass}
|
||||
const { error } = await supabase
|
||||
.from('settings')
|
||||
.upsert({ id: 'smtp', ...payload });
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
try {
|
||||
let client: any;
|
||||
try {
|
||||
client = createAdminClient();
|
||||
} catch {
|
||||
client = await createClient();
|
||||
}
|
||||
|
||||
const payload = await request.json();
|
||||
const { error } = await client
|
||||
.from('settings')
|
||||
.upsert({ id: 'smtp', ...payload });
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving SMTP settings:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'SMTP settings saved' });
|
||||
} catch (err: any) {
|
||||
console.error('SMTP settings POST error:', err);
|
||||
return NextResponse.json({ error: err.message || 'Fehler beim Speichern' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ message: 'SMTP settings saved' });
|
||||
}
|
||||
|
||||
25
shop/app/api/cron/sync-companies/route.ts
Normal file
25
shop/app/api/cron/sync-companies/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { syncCompaniesFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Check authorization header
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// Secure the cron endpoint in production
|
||||
if (process.env.NODE_ENV === 'production' && cronSecret) {
|
||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await syncCompaniesFromLicServer()
|
||||
return NextResponse.json({ success: true, count: res.count })
|
||||
} catch (err: any) {
|
||||
console.error('[cron] Sync failed:', err)
|
||||
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
25
shop/app/api/cron/sync-customers/route.ts
Normal file
25
shop/app/api/cron/sync-customers/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { syncCustomersFromLicServer } from '@/lib/actions/companies'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Check authorization header
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
// Secure the cron endpoint in production
|
||||
if (process.env.NODE_ENV === 'production' && cronSecret) {
|
||||
if (authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await syncCustomersFromLicServer()
|
||||
return NextResponse.json({ success: true, count: res.count })
|
||||
} catch (err: any) {
|
||||
console.error('[cron] Customer sync failed:', err)
|
||||
return NextResponse.json({ error: err.message || 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal file
85
shop/app/api/licserver/partners/[id]/customers/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { CustomerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners/[id]/customers
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners/{id}/customers
|
||||
* [id] must be a valid UUID.
|
||||
* Query params forwarded: search, page, pageSize
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Basic UUID format guard
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Forward query params ───────────────────────────────────────────────
|
||||
const { searchParams } = req.nextUrl
|
||||
const upstream = new URL(`${cfg.base_url}/api-v1/partners/${id}/customers`)
|
||||
const search = searchParams.get('search')
|
||||
const page = searchParams.get('page')
|
||||
const pageSize = searchParams.get('pageSize')
|
||||
if (search) upstream.searchParams.set('search', search)
|
||||
if (page) upstream.searchParams.set('page', page)
|
||||
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(
|
||||
upstream.toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: CustomerDtoPagedResult = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partner/:id/customers fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
74
shop/app/api/licserver/partners/[id]/route.ts
Normal file
74
shop/app/api/licserver/partners/[id]/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { PartnerDto, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners/[id]
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners/{id}
|
||||
* [id] must be a valid UUID.
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Basic UUID format guard
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid partner ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${cfg.base_url}/api-v1/partners/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: PartnerDto = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partner/:id fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
71
shop/app/api/licserver/partners/route.ts
Normal file
71
shop/app/api/licserver/partners/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getLicServerConfig } from '@/lib/actions/licserver-config'
|
||||
import type { PartnerDtoPagedResult, LicServerProblemDetails } from '@/lib/types/licserver'
|
||||
|
||||
/**
|
||||
* GET /api/licserver/partners
|
||||
*
|
||||
* Proxy to LicServer GET /api-v1/partners
|
||||
* Query params forwarded: search, page, pageSize
|
||||
*
|
||||
* Secured: requires a valid Supabase session.
|
||||
* base_url + api_key are read from DB settings (admin-configurable),
|
||||
* with fallback to LICSERVER_BASE_URL / LICSERVER_API_KEY env vars.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// ── Auth guard ─────────────────────────────────────────────────────────
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ── Load config from DB (falls back to env vars) ──────────────────────
|
||||
const cfg = await getLicServerConfig()
|
||||
if (!cfg.base_url || !cfg.api_key) {
|
||||
console.error('[licserver] base_url or api_key not configured (DB or env)')
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht konfiguriert. Bitte in den Admin-Einstellungen hinterlegen.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Forward query params ───────────────────────────────────────────────
|
||||
const { searchParams } = req.nextUrl
|
||||
const upstream = new URL(`${cfg.base_url}/api-v1/partners`)
|
||||
const search = searchParams.get('search')
|
||||
const page = searchParams.get('page')
|
||||
const pageSize = searchParams.get('pageSize')
|
||||
if (search) upstream.searchParams.set('search', search)
|
||||
if (page) upstream.searchParams.set('page', page)
|
||||
if (pageSize) upstream.searchParams.set('pageSize', pageSize)
|
||||
|
||||
// ── Proxy request ──────────────────────────────────────────────────────
|
||||
try {
|
||||
const res = await fetch(upstream.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Api-Key': cfg.api_key,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
// Internal container network — short timeout
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const problem: LicServerProblemDetails = await res.json().catch(() => ({}))
|
||||
return NextResponse.json(problem, { status: res.status })
|
||||
}
|
||||
|
||||
const data: PartnerDtoPagedResult = await res.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[licserver] partners fetch error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'LicServer nicht erreichbar' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
132
shop/app/api/orders/[id]/download/route.ts
Normal file
132
shop/app/api/orders/[id]/download/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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, {
|
||||
order: order,
|
||||
orderSnapshot: order.order_data,
|
||||
customer: order.customer_data,
|
||||
})
|
||||
);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
347
shop/app/api/orders/checkout/route.ts
Normal file
347
shop/app/api/orders/checkout/route.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
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 { generateOrderEmailHtml, generateOrderEmailSubject } from '@/lib/actions/email-templates';
|
||||
import { validateWizardSelections } from '@/lib/actions/validation';
|
||||
|
||||
|
||||
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
|
||||
const year = new Date().getFullYear();
|
||||
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' || dbUser?.role === 'verwaltung';
|
||||
if (!isAdminUser && !dbUser?.company_id) {
|
||||
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { items, customerProfile, endCustomerId, endCustomer, lastLicenseDate } = 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;
|
||||
const feeProductIds = new Set<string>();
|
||||
|
||||
for (const item of groupItems) {
|
||||
const itemSnapshot = buildOrderSnapshot(
|
||||
item.selections,
|
||||
products,
|
||||
categories,
|
||||
type === 'purchase' ? 'one_time' : 'monthly',
|
||||
item.moduleQuantities,
|
||||
type === 'purchase' ? lastLicenseDate : null
|
||||
);
|
||||
const itemsWithDevice = itemSnapshot.items.map(i => ({
|
||||
...i,
|
||||
device_name: item.deviceName,
|
||||
license_number: item.licenseNumber
|
||||
}));
|
||||
orderItemsList.push(...itemsWithDevice);
|
||||
total += itemSnapshot.total;
|
||||
|
||||
// Echte linked fee products ermitteln
|
||||
if (item.selections) {
|
||||
for (const catId in item.selections) {
|
||||
const sel = item.selections[catId];
|
||||
|
||||
if (sel.productId) {
|
||||
const p = products.find((prod: any) => prod.id === sel.productId);
|
||||
if (p && p.linked_fee_product_id) {
|
||||
feeProductIds.add(p.linked_fee_product_id);
|
||||
}
|
||||
}
|
||||
|
||||
sel.productIds?.forEach((pId: string) => {
|
||||
const p = products.find((prod: any) => prod.id === pId);
|
||||
if (p && p.linked_fee_product_id) {
|
||||
feeProductIds.add(p.linked_fee_product_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feeProductIds.forEach(feeProdId => {
|
||||
const feeProduct = products.find((p: any) => p.id === feeProdId);
|
||||
if (feeProduct) {
|
||||
const isIntervalMatch =
|
||||
(type === 'purchase' && feeProduct.billing_interval === 'one_time') ||
|
||||
(type === 'subscription' && feeProduct.billing_interval === 'monthly');
|
||||
|
||||
if (isIntervalMatch) {
|
||||
orderItemsList.push({
|
||||
category_id: 'dienste-gebuehren',
|
||||
category_name: 'Dienste & Gebühren',
|
||||
product_id: feeProduct.id,
|
||||
product_name: feeProduct.name,
|
||||
base_price: Number(feeProduct.base_price),
|
||||
billing_interval: feeProduct.billing_interval,
|
||||
selected_modules: [],
|
||||
item_total: Number(feeProduct.base_price),
|
||||
device_name: 'Zusatzleistung'
|
||||
});
|
||||
total += Number(feeProduct.base_price);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const orderSnapshot = {
|
||||
schema_version: 1,
|
||||
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
|
||||
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,
|
||||
last_license_date: type === 'purchase' ? lastLicenseDate : null
|
||||
};
|
||||
|
||||
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 emailTemplate = generateOrderEmailHtml({
|
||||
orderNumber: order.order_number,
|
||||
status: 'pending',
|
||||
formattedDate,
|
||||
customerCompanyName: customerSnapshot.company_name,
|
||||
items,
|
||||
taxRate,
|
||||
oneTimeNet,
|
||||
monthlyNet,
|
||||
});
|
||||
|
||||
const mailSubject = generateOrderEmailSubject(order.order_number, 'pending');
|
||||
|
||||
const { data: bufferData, error: downloadError } = await supabase
|
||||
.storage
|
||||
.from('invoices')
|
||||
.download(fileName);
|
||||
|
||||
const attachments: any[] = [];
|
||||
if (!downloadError && bufferData) {
|
||||
const buffer = Buffer.from(await bufferData.arrayBuffer());
|
||||
attachments.push({
|
||||
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
|
||||
content: buffer,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
}
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: mailSubject,
|
||||
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">
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Datenschutz() {
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||
<Lock className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Datenschutzerklärung
|
||||
</h1>
|
||||
<p className="text-slate-400">Informationen über die Verarbeitung Ihrer personenbezogenen Daten.</p>
|
||||
|
||||
@@ -2,110 +2,221 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light Mode: Maximierter Kontrast (Reines Weißer Hintergrund + Tiefschwarzer Text) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71% 4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71% 4%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71% 4%;
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Primary: Cyan/Blau lesbarer gemacht */
|
||||
--primary: 199 100% 35%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
:root {
|
||||
/* OKLCH Zentral-Farbsystem */
|
||||
--oklch-background: 0.145 0.01 148;
|
||||
--oklch-surface: 0.20 0.01 148;
|
||||
--oklch-surface-hover: 0.25 0.012 148;
|
||||
--oklch-primary: 0.68 0.18 255;
|
||||
--oklch-primary-hover: 0.75 0.18 255;
|
||||
--oklch-secondary: 0.76 0.15 155;
|
||||
--oklch-text-main: 0.98 0 0;
|
||||
--oklch-text-muted: 0.70 0.01 148;
|
||||
--oklch-border: 0.31 0.012 148;
|
||||
|
||||
/* Muted & Accent: Deutlich dunklerer Text für Barrierefreiheit */
|
||||
--secondary: 220 14% 90%;
|
||||
--secondary-foreground: 224 71% 4%;
|
||||
--muted: 220 14% 92%;
|
||||
--muted-foreground: 220 20% 28%;
|
||||
/* Vorher 40% Helligkeit, jetzt 28% (viel dunkler) */
|
||||
--accent: 220 14% 90%;
|
||||
--accent-foreground: 224 71% 4%;
|
||||
/* Dynamische Branding Variablen */
|
||||
--primary-custom: #2563eb;
|
||||
--accent-custom: #38bdf8;
|
||||
--success-custom: #10b981;
|
||||
--warning-custom: #f59e0b;
|
||||
--destructive-custom: #ef4444;
|
||||
--bg-glow-1: #3b82f6;
|
||||
--bg-glow-2: #1d4ed8;
|
||||
--gradient-from: #2563eb;
|
||||
--gradient-to: #1e40af;
|
||||
|
||||
--destructive: 0 84% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 14% 75%;
|
||||
/* Grenzen sichtbarer gemacht */
|
||||
--input: 220 14% 75%;
|
||||
--ring: 199 100% 35%;
|
||||
--radius: 0.75rem;
|
||||
/* Classic HSL Mappings */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* 100% Dynamische Farbbindung für das gesamte Webshop Design-System */
|
||||
.bg-blue-600, .bg-blue-500, .bg-violet-600, .bg-purple-600, .bg-sky-500, .bg-cyan-600, .bg-indigo-600 {
|
||||
background-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.hover\:bg-blue-500:hover, .hover\:bg-violet-500:hover, .hover\:bg-blue-600:hover {
|
||||
background-color: var(--primary-custom) !important;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.text-blue-500, .text-blue-400, .text-blue-600, .text-violet-400, .text-sky-400, .text-sky-300, .text-indigo-400 {
|
||||
color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
.border-blue-500, .border-blue-600, .border-violet-500, .border-sky-500 {
|
||||
border-color: var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Transparenz-Schichten & Badges mit color-mix */
|
||||
[class*="bg-blue-500/"], [class*="bg-violet-500/"], [class*="bg-purple-500/"], [class*="bg-sky-500/"] {
|
||||
background-color: color-mix(in srgb, var(--primary-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-blue-500/"], [class*="border-violet-500/"], [class*="border-purple-500/"], [class*="border-sky-500/"] {
|
||||
border-color: color-mix(in srgb, var(--primary-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Gradients */
|
||||
.from-blue-600, .from-blue-500, .from-violet-600 {
|
||||
--tw-gradient-from: var(--gradient-from, var(--primary-custom)) !important;
|
||||
--tw-gradient-to: rgb(255 255 255 / 0) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
.to-indigo-600, .to-indigo-500, .to-sky-500, .to-sky-400, .to-violet-500 {
|
||||
--tw-gradient-to: var(--gradient-to, var(--accent-custom)) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Signalfarben (Success, Warning, Destructive) */
|
||||
.bg-emerald-500, .bg-green-500, .bg-emerald-600, .bg-green-600 {
|
||||
background-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.text-emerald-400, .text-green-400, .text-emerald-500, .text-green-500, .text-green-600 {
|
||||
color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
.border-emerald-500, .border-green-500 {
|
||||
border-color: var(--success-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-emerald-500/"], [class*="bg-green-500/"] {
|
||||
background-color: color-mix(in srgb, var(--success-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-emerald-500/"], [class*="border-green-500/"] {
|
||||
border-color: color-mix(in srgb, var(--success-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-amber-500, .bg-amber-600 {
|
||||
background-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.text-amber-400, .text-amber-500, .text-amber-600 {
|
||||
color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
.border-amber-500, .border-amber-600 {
|
||||
border-color: var(--warning-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-amber-500/"] {
|
||||
background-color: color-mix(in srgb, var(--warning-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-amber-500/"] {
|
||||
border-color: color-mix(in srgb, var(--warning-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
.bg-red-500, .bg-red-600, .bg-rose-500, .bg-rose-600 {
|
||||
background-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.text-red-500, .text-red-400, .text-rose-400, .text-rose-500 {
|
||||
color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
.border-red-500, .border-rose-500 {
|
||||
border-color: var(--destructive-custom) !important;
|
||||
}
|
||||
|
||||
[class*="bg-red-500/"], [class*="bg-rose-500/"] {
|
||||
background-color: color-mix(in srgb, var(--destructive-custom) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
[class*="border-red-500/"], [class*="border-rose-500/"] {
|
||||
border-color: color-mix(in srgb, var(--destructive-custom) 35%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Background Glowing Orbs & Dynamic Surfaces */
|
||||
.blur-\[120px\] {
|
||||
background-color: var(--bg-glow-1) !important;
|
||||
}
|
||||
|
||||
.bg-\[\#020617\] {
|
||||
background-color: oklch(var(--oklch-background)) !important;
|
||||
}
|
||||
|
||||
/* Dynamische Buttons & Badges über globale CSS Variablen */
|
||||
.btn-primary-theme, .bg-primary-theme {
|
||||
background-color: var(--button-bg, var(--primary-custom)) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.text-highlight-theme {
|
||||
color: var(--text-highlight, var(--accent-custom)) !important;
|
||||
}
|
||||
|
||||
.border-glow-theme {
|
||||
border-color: var(--card-border-glow, color-mix(in srgb, var(--accent-custom) 35%, transparent)) !important;
|
||||
}
|
||||
|
||||
.ring-theme {
|
||||
--tw-ring-color: var(--ring-color, var(--primary-custom)) !important;
|
||||
}
|
||||
|
||||
input, select, textarea, option {
|
||||
background-color: oklch(var(--oklch-surface)) !important;
|
||||
color: oklch(var(--oklch-text-main)) !important;
|
||||
border: 1px solid oklch(var(--oklch-border) / 0.4) !important;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--primary-custom) !important;
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px var(--primary-custom) !important;
|
||||
}
|
||||
|
||||
option {
|
||||
color: oklch(var(--oklch-text-main)) !important;
|
||||
background-color: oklch(var(--oklch-surface)) !important;
|
||||
}
|
||||
|
||||
/* Eye-Catcher & Rüttel-Animation für fehlende Pflichtkategorien */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15%, 55% { transform: translateX(-4px); }
|
||||
35%, 75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@keyframes eye-catcher-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 15px rgba(244, 63, 94, 0.6), inset 0 0 8px rgba(244, 63, 94, 0.3);
|
||||
border-color: rgba(244, 63, 94, 0.9);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark Mode: Reines Tiefschwarz erhöht den Kontrast zu weißem Text drastisch */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
/* Muted Text: Helligkeit massiv erhöht, damit Beschreibungen lesbar sind */
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 75%;
|
||||
/* Vorher 56.9% Helligkeit, jetzt 75% (viel heller) */
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
/* Helleres Rot für dunklen Hintergrund */
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 25%;
|
||||
/* Grenzen im Dunkeln besser sichtbar */
|
||||
--input: 240 3.7% 25%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
50% {
|
||||
box-shadow: 0 0 28px rgba(239, 68, 68, 0.9), inset 0 0 14px rgba(239, 68, 68, 0.5);
|
||||
border-color: rgba(239, 68, 68, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Automatischer High-Contrast-Modus für Betriebssystem-Vorgaben */
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--foreground: 0 0% 0%;
|
||||
--muted-foreground: 0 0% 0%;
|
||||
--border: 0 0% 0%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 100%;
|
||||
--muted-foreground: 0 0% 100%;
|
||||
--border: 0 0% 100%;
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Glassmorphism Kontraste geschärft (höhere Deckkraft) */
|
||||
.glass {
|
||||
@apply bg-white/20 backdrop-blur-md border border-white/40;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-black/40 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-sky-500 to-blue-600 dark:from-primary dark:to-blue-400;
|
||||
}
|
||||
.animate-eye-catcher {
|
||||
animation: eye-catcher-glow 1.2s ease-in-out infinite, shake 0.8s ease-in-out infinite !important;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function Impressum() {
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-white">
|
||||
Impressum
|
||||
</h1>
|
||||
<p className="text-slate-400">Angaben gemäß § 5 TMG.</p>
|
||||
@@ -78,12 +78,12 @@ export default function Impressum() {
|
||||
<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 des Onlineangebotes</h3>
|
||||
<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 Angebote sind freibleibend und unverbindlich. Der Autor behält es sich ausdrücklich vor, Teile der Seiten oder das gesamte Angebot ohne gesonderte Ankündigung zu verändern, zu ergänzen, zu löschen oder die Veröffentlichung zeitweise oder endgültig einzustellen.
|
||||
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>
|
||||
|
||||
@@ -93,14 +93,14 @@ export default function Impressum() {
|
||||
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 Internetangebotes 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.
|
||||
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 des Internetangebotes 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.
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { InactivityTracker } from "@/components/inactivity-tracker";
|
||||
import { HeaderWrapper } from "@/components/HeaderWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
@@ -33,6 +33,7 @@ export default function RootLayout({
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -99,6 +104,13 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
Kunden-ID: <span className="font-mono">{customer.id.slice(0, 8)}…</span>
|
||||
</p>
|
||||
</div>
|
||||
{!customer.is_anonymized && (
|
||||
<Link href={`/order?customer_id=${customer.id}`} className="ml-auto">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_15px_rgba(59,130,246,0.3)] text-xs">
|
||||
<ShoppingBag className="w-4 h-4" /> Kunde updaten / bestellen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{customer.is_anonymized && (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1 ml-auto">
|
||||
<AlertTriangle className="w-3 h-3" /> Anonymisiert
|
||||
@@ -126,6 +138,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>
|
||||
@@ -177,8 +193,9 @@ export default function CustomerDetailPage({ params }: { params: Promise<{ id: s
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 p-4 rounded-lg bg-red-500/10 border border-red-500/30">
|
||||
<p className="text-sm text-red-300 font-semibold">
|
||||
⚠️ Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.
|
||||
<p className="text-sm text-red-300 font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 text-red-400" />
|
||||
<span>Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben.</span>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,41 +1,27 @@
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getEndCustomers } from '@/lib/actions/end-customers'
|
||||
import type { EndCustomer } from '@/lib/types'
|
||||
import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react'
|
||||
import { getPartnerCustomersWithDevices } from '@/lib/actions/queries'
|
||||
import type { EndCustomerWithDevices } from '@/lib/types'
|
||||
import { ArrowLeft, Building2, Plus, AlertTriangle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { CustomerAccordionList } from '@/components/customer-accordion-list'
|
||||
|
||||
export default async function MyCustomersPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/auth/login')
|
||||
|
||||
let customers: EndCustomer[] = []
|
||||
let customers: EndCustomerWithDevices[] = []
|
||||
let fetchError: string | null = null
|
||||
try {
|
||||
customers = await getEndCustomers()
|
||||
customers = await getPartnerCustomersWithDevices()
|
||||
} catch (err: any) {
|
||||
console.error("Error loading end customers:", err)
|
||||
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Endkunden."
|
||||
console.error("Error loading partner customers with orders:", err)
|
||||
fetchError = err.message || "Es gab ein Problem beim Laden Ihrer Kunden und Kassen."
|
||||
}
|
||||
|
||||
// Anzahl Bestellungen pro Endkunde
|
||||
const { data: orderCounts, error: orderError } = await supabase.from('orders').select('end_customer_id').not('end_customer_id', 'is', null);
|
||||
if (orderError) {
|
||||
console.error("Error loading order counts:", orderError)
|
||||
}
|
||||
|
||||
const countMap: Record<string, number> = {}
|
||||
;(orderCounts ?? []).forEach(o => {
|
||||
if (o.end_customer_id) {
|
||||
countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 1
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
@@ -50,10 +36,10 @@ export default async function MyCustomersPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
Meine Kunden
|
||||
Kunden & Kassen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Verwalten Sie Ihre Endkunden – deren Daten werden für Bestellungen verwendet.
|
||||
Verwalten Sie Ihre Kunden und deren zugewiesene Kassen / Abonnements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,86 +56,8 @@ export default async function MyCustomersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Kunden */}
|
||||
{customers.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Building2 className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Noch keine Endkunden angelegt.</p>
|
||||
<Link href="/my-customers/new">
|
||||
<Button><Plus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Kundenliste */}
|
||||
{customers.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left">
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Unternehmen</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden sm:table-cell">Adresse</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden md:table-cell">Bestellungen</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Status</th>
|
||||
<th className="pb-3 text-xs font-semibold text-slate-400 uppercase tracking-wider text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{customers.map(customer => (
|
||||
<tr key={customer.id} className="hover:bg-white/3 transition-colors">
|
||||
<td className="py-4 pr-4">
|
||||
<p className="font-semibold text-white">{customer.company_name}</p>
|
||||
{(customer.first_name || customer.last_name) && (
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
|
||||
</p>
|
||||
)}
|
||||
{customer.vat_id && (
|
||||
<p className="text-xs text-slate-500">{customer.vat_id}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 pr-4 hidden sm:table-cell">
|
||||
<p className="text-sm text-slate-300">
|
||||
{customer.street ?? '–'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{[customer.zip, customer.city].filter(Boolean).join(' ') || '–'}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-4 pr-4 hidden md:table-cell">
|
||||
<Link href={`/my-orders?customer=${customer.id}`} className="group">
|
||||
<span className="text-primary font-bold group-hover:underline">
|
||||
{countMap[customer.id] ?? 0}
|
||||
</span>
|
||||
<span className="text-slate-500 text-xs ml-1">Bestellung(en)</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-4 pr-4">
|
||||
{customer.is_anonymized ? (
|
||||
<Badge className="bg-red-500/10 text-red-400 border-red-500/20 gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Anonymisiert
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/20">Aktiv</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 text-right">
|
||||
{!customer.is_anonymized && (
|
||||
<Link href={`/my-customers/${customer.id}`}>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white gap-1">
|
||||
<Edit2 className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Akkordeon-Ansicht */}
|
||||
<CustomerAccordionList customers={customers} />
|
||||
|
||||
{/* DSGVO-Hinweis */}
|
||||
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 text-xs text-amber-300/80 space-y-1">
|
||||
|
||||
386
shop/app/my-orders/MyOrdersClient.tsx
Normal file
386
shop/app/my-orders/MyOrdersClient.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package, Search, ChevronLeft, ChevronRight, Building2, Monitor, RefreshCw, FileText } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { Order } from '@/lib/types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
interface MyOrdersClientProps {
|
||||
orders: Order[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function MyOrdersClient({ orders, error }: MyOrdersClientProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
const fmt = (val: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val)
|
||||
|
||||
// Volltextsuche filtern
|
||||
const filteredOrders = useMemo(() => {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
if (!term) return orders
|
||||
|
||||
return orders.filter(o => {
|
||||
// Anfragenummer
|
||||
if (o.order_number?.toString().toLowerCase().includes(term)) return true
|
||||
|
||||
// Status
|
||||
const label = statusLabel[o.status] || o.status
|
||||
if (label.toLowerCase().includes(term) || o.status.toLowerCase().includes(term)) return true
|
||||
|
||||
// Datum
|
||||
const dateStr = new Date(o.created_at).toLocaleDateString('de-DE')
|
||||
if (dateStr.includes(term)) return true
|
||||
|
||||
// Endkunde (Firma / Name)
|
||||
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
||||
if (customer) {
|
||||
if (customer.company_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.first_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.last_name?.toLowerCase().includes(term)) return true
|
||||
if (customer.customer_number?.toLowerCase().includes(term)) return true
|
||||
}
|
||||
|
||||
// Notizen
|
||||
if (o.notes?.toLowerCase().includes(term)) return true
|
||||
|
||||
// Kassen & Module
|
||||
const items = o.order_data?.items ?? []
|
||||
for (const item of items) {
|
||||
if (item.device_name?.toLowerCase().includes(term)) return true
|
||||
if (item.product_name?.toLowerCase().includes(term)) return true
|
||||
if (item.license_number?.toLowerCase().includes(term)) return true
|
||||
|
||||
for (const mod of item.selected_modules ?? []) {
|
||||
if (mod.module_name?.toLowerCase().includes(term)) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}, [orders, searchTerm])
|
||||
|
||||
// Paginierung berechnen
|
||||
const totalPages = Math.ceil(filteredOrders.length / pageSize) || 1
|
||||
const paginatedOrders = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return filteredOrders.slice(start, start + pageSize)
|
||||
}, [filteredOrders, currentPage, pageSize])
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white px-4 py-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||
Meine Anfragen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Alle Ihre Bestellungen und Konfigurationen auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/order">
|
||||
<Button className="bg-primary hover:bg-primary/90 text-white font-bold gap-2 shadow-[0_0_20px_rgba(59,130,246,0.3)]">
|
||||
+ Neue Anfrage erstellen
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Suche & Statistik */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-stretch sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Suchen nach Anfragenr., Kunde, Kasse, Produkt, Lizenz..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10 bg-slate-900/80 border-white/10 text-white placeholder:text-slate-500 rounded-xl h-11 text-sm focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 bg-slate-900/50 px-4 py-2.5 rounded-xl border border-white/10 shrink-0 text-center flex items-center justify-center gap-2">
|
||||
<span>Anfragen:</span>
|
||||
<span className="text-white font-bold text-sm">{filteredOrders.length}</span>
|
||||
{searchTerm && <span className="text-slate-500">(gefiltert aus {orders.length})</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||
Fehler beim Laden der Anfragen: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Anfragen (aus DB) */}
|
||||
{!error && orders.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Package className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||
<Link href="/order">
|
||||
<Button>Jetzt konfigurieren</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Keine Treffer nach Suche */}
|
||||
{!error && orders.length > 0 && filteredOrders.length === 0 && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
|
||||
<Search className="w-10 h-10 text-slate-600" />
|
||||
<p className="text-slate-300 font-medium">Keine Anfragen für "{searchTerm}" gefunden.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchTerm('')} className="border-white/10 text-xs">
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Anfragenliste */}
|
||||
{paginatedOrders.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{paginatedOrders.map((o) => {
|
||||
const items = o.order_data?.items ?? []
|
||||
const customer = (o as any).end_customer_data || o.order_data?.end_customer
|
||||
|
||||
return (
|
||||
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-all duration-300 rounded-2xl overflow-hidden shadow-xl">
|
||||
<CardContent className="p-6 space-y-5">
|
||||
{/* Header-Zeile der Karte */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap pb-4 border-b border-white/5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Anfragenummer</span>
|
||||
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
||||
{statusLabel[o.status] ?? o.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="font-mono font-extrabold text-primary text-xl">#{o.order_number}</p>
|
||||
</div>
|
||||
|
||||
{/* Endkunde Details (falls vorhanden) */}
|
||||
{customer && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 px-3 flex items-center gap-3">
|
||||
<Building2 className="w-4 h-4 text-sky-400 shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="text-white font-bold">{customer.company_name || `${customer.first_name} ${customer.last_name}`}</p>
|
||||
{(customer.first_name || customer.city) && (
|
||||
<p className="text-slate-400 text-[11px]">
|
||||
{[customer.first_name && customer.last_name ? `${customer.first_name} ${customer.last_name}` : null, customer.city].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 text-right ml-auto">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider font-semibold">Datum & Preis</p>
|
||||
<p className="text-slate-300 text-xs">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="font-extrabold text-white text-lg">
|
||||
{fmt(o.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kassen & Produkte Detail-Übersicht (Angelehnt an Kundenliste DeviceCard) */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||
<Monitor className="w-3.5 h-3.5 text-primary" />
|
||||
Kassenaufstellung ({items.length} {items.length === 1 ? 'Kasse' : 'Kassen'}):
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{items.map((item, idx) => {
|
||||
const devName = item.device_name || `Kasse ${idx + 1}`
|
||||
const licNum = item.license_number
|
||||
const modules = item.selected_modules || []
|
||||
|
||||
// Chips für Hauptprodukt und Module
|
||||
const chips: string[] = []
|
||||
if (item.product_name) chips.push(item.product_name)
|
||||
for (const mod of modules) {
|
||||
if (mod.module_name) chips.push(mod.module_name)
|
||||
}
|
||||
|
||||
const itemPrice = (item as any).price || item.base_price || 0
|
||||
const modulesPrice = modules.reduce((sum: number, m: any) => sum + ((m.price || 0) * (m.quantity || 1)), 0)
|
||||
const deviceTotal = itemPrice + modulesPrice
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 hover:border-slate-700 transition-all flex flex-col md:flex-row md:items-start justify-between gap-4 relative overflow-hidden"
|
||||
>
|
||||
{/* Kassen-Info links */}
|
||||
<div className="space-y-2 min-w-[200px] flex-1">
|
||||
{/* Kassen-Name + Lizenznummer daneben + Intervall */}
|
||||
<div className="flex items-center gap-2 flex-wrap border-b border-white/5 pb-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
||||
{devName}
|
||||
</span>
|
||||
|
||||
{licNum && (
|
||||
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
||||
{licNum}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
|
||||
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Produkt + Modul Chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{chips.map((chip, cIdx) => (
|
||||
<span
|
||||
key={cIdx}
|
||||
className="text-xs bg-white/5 border border-white/10 rounded-md px-2 py-0.5 text-slate-300"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preis rechts */}
|
||||
{deviceTotal > 0 && (
|
||||
<div className="text-left md:text-right shrink-0 border-t md:border-t-0 pt-2 md:pt-0 border-white/5">
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-wider">Kassenwert</p>
|
||||
<p className="font-bold text-white text-sm">
|
||||
{fmt(deviceTotal)}
|
||||
{item.billing_interval === 'monthly' && <span className="text-xs font-normal text-slate-400"> / mtl.</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notizen (falls vorhanden) */}
|
||||
{o.notes && (
|
||||
<div className="bg-slate-900/40 p-2.5 rounded-xl border border-white/5 text-xs text-slate-400 flex items-start gap-2">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-500 shrink-0 mt-0.5" />
|
||||
<p><span className="font-semibold text-slate-300">Anmerkungen:</span> {o.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aktionsleiste */}
|
||||
<div className="flex gap-2 flex-wrap pt-2 border-t border-white/5 items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Link href={`/order/success?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs font-bold rounded-xl h-8">
|
||||
Details ansehen
|
||||
</Button>
|
||||
</Link>
|
||||
{o.status !== 'completed' && (
|
||||
<Link href={`/order?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-amber-500/30 hover:bg-amber-500/10 hover:border-amber-500/50 text-amber-400 text-xs font-bold gap-1 rounded-xl h-8">
|
||||
<RefreshCw className="w-3 h-3" /> Konfiguration bearbeiten
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{o.pdf_url && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
||||
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Ansehen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white h-8">
|
||||
<a href={`/api/orders/${o.id}/download`} download>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginierung (10 pro Seite) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
<span className="text-xs text-slate-400">
|
||||
Seite <span className="text-white font-bold">{currentPage}</span> von <span className="text-white font-bold">{totalPages}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" /> Vorherige
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="border-white/10 text-xs gap-1 rounded-xl"
|
||||
>
|
||||
Nächste <ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Download, ExternalLink, ShoppingBag, ArrowLeft, Package } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Order } from '@/lib/types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
import { MyOrdersClient } from './MyOrdersClient'
|
||||
|
||||
export default async function MyOrdersPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -25,141 +9,27 @@ 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">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold tracking-tight flex items-center gap-3">
|
||||
<ShoppingBag className="w-8 h-8 text-primary" />
|
||||
Meine Anfragen
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Alle Ihre Anfragen auf einen Blick – inkl. aktuellem Status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-xl p-4 text-sm">
|
||||
Fehler beim Laden der Anfragen: {error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Anfragen */}
|
||||
{!error && (!orders || orders.length === 0) && (
|
||||
<Card className="glass-dark border-white/10">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<Package className="w-12 h-12 text-slate-600" />
|
||||
<p className="text-slate-400 text-lg">Sie haben noch keine Anfragen aufgegeben.</p>
|
||||
<Link href="/order">
|
||||
<Button>Jetzt konfigurieren</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Anfragenliste */}
|
||||
<div className="space-y-4">
|
||||
{(orders ?? []).map((order) => {
|
||||
const o = order as Order
|
||||
const items = o.order_data?.items ?? []
|
||||
return (
|
||||
<Card key={o.id} className="glass-dark border-white/10 hover:border-white/20 transition-colors">
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{/* Kopfzeile */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Anfragenummer</p>
|
||||
<p className="font-mono font-bold text-primary text-lg">#{o.order_number}</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Datum</p>
|
||||
<p className="text-white text-sm">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Status</p>
|
||||
<Badge className={statusClass[o.status] ?? 'bg-slate-500/20 text-slate-300'}>
|
||||
{statusLabel[o.status] ?? o.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Gesamt</p>
|
||||
<p className="font-bold text-white text-lg">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(o.total_price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Produkte kurz */}
|
||||
{items.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item, i) => (
|
||||
<span
|
||||
key={item.product_id}
|
||||
className="text-xs bg-white/5 border border-white/10 rounded-full px-3 py-1 text-slate-300"
|
||||
>
|
||||
{item.product_name}
|
||||
{item.selected_modules.length > 0 && (
|
||||
<span className="text-slate-500 ml-1">+{item.selected_modules.length} Module</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aktionen */}
|
||||
<div className="flex gap-2 flex-wrap pt-1">
|
||||
<Link href={`/order/success?id=${o.id}`}>
|
||||
<Button variant="outline" size="sm" className="border-white/10 hover:bg-white/10 text-xs">
|
||||
Details ansehen
|
||||
</Button>
|
||||
</Link>
|
||||
{o.status === 'pending' && (
|
||||
<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" /> 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>
|
||||
<Download className="w-3 h-3 mr-1" /> PDF
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MyOrdersClient
|
||||
orders={(orders as Order[]) || []}
|
||||
error={error ? error.message : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
90
shop/app/not-found.tsx
Normal file
90
shop/app/not-found.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ArrowLeft, Home } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
<AsciiShaderBackground />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center justify-center min-h-screen px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-center space-y-8 max-w-lg"
|
||||
>
|
||||
{/* Große 404 */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.1, type: 'spring', stiffness: 100 }}
|
||||
className="text-[10rem] md:text-[14rem] font-black leading-none tracking-tighter bg-gradient-to-b from-white via-slate-300 to-slate-600 bg-clip-text text-transparent select-none"
|
||||
>
|
||||
404
|
||||
</motion.h1>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-white">
|
||||
Seite nicht gefunden
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm md:text-base leading-relaxed max-w-md mx-auto">
|
||||
Die angeforderte Seite existiert nicht oder wurde verschoben.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-3 pt-4"
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 px-8 text-base gap-2 bg-gradient-to-r from-blue-600 to-sky-500 hover:from-blue-500 hover:to-sky-400 border-0 shadow-[0_0_20px_rgba(56,189,248,0.25)] hover:shadow-[0_0_30px_rgba(56,189,248,0.4)] transition-all"
|
||||
>
|
||||
<Link href="/">
|
||||
<Home className="w-4 h-4" />
|
||||
Zur Startseite
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="h-12 px-8 text-base gap-2 text-slate-400 hover:text-white border border-white/10 hover:border-white/20 hover:bg-white/5"
|
||||
onClick={() => typeof window !== 'undefined' && window.history.back()}
|
||||
>
|
||||
<button type="button">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurück
|
||||
</button>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Subtle info */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.7 }}
|
||||
className="text-xs text-slate-600 pt-8"
|
||||
>
|
||||
Fehlercode 404 · CASPOS Shop
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,36 +3,46 @@ 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'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
searchParams: Promise<{ id?: string; orderId?: string; mode?: string; customer_id?: string; device_id?: string }>
|
||||
}
|
||||
|
||||
export default async function OrderPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const orderId = params.orderId || params.id
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-white selection:bg-primary/30">
|
||||
<div className="container mx-auto py-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight mb-4 text-gradient">
|
||||
Konfigurieren Sie Ihre Lösung
|
||||
</h1>
|
||||
<p className="text-slate-400 text-lg max-w-2xl mx-auto">
|
||||
Wählen Sie das passende Paket und die benötigten Module für Ihr Business.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<Suspense fallback={<div className="h-96 w-full animate-pulse bg-white/5 rounded-2xl" />}>
|
||||
<OrderDataWrapper orderId={params.id} />
|
||||
<OrderDataWrapper
|
||||
orderId={orderId}
|
||||
mode={params.mode}
|
||||
customerId={params.customer_id}
|
||||
deviceId={params.device_id}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
async function OrderDataWrapper({
|
||||
orderId,
|
||||
mode,
|
||||
customerId,
|
||||
deviceId,
|
||||
}: {
|
||||
orderId?: string
|
||||
mode?: string
|
||||
customerId?: string
|
||||
deviceId?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
@@ -40,13 +50,28 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
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' || userData?.role === 'verwaltung'
|
||||
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()
|
||||
@@ -60,13 +85,14 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
const isAdmin = userData?.role === 'admin'
|
||||
const isAdmin = userData?.role === 'admin' || userData?.role === 'verwaltung'
|
||||
const isOwner = orderData.user_id === user.id
|
||||
if (!isAdmin && !isOwner) {
|
||||
const isCompanyMember = userData?.company_id && orderData.company_id === userData.company_id
|
||||
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
if (orderData.status !== 'pending' && !isAdmin) {
|
||||
if (orderData.status === 'completed' && !isAdmin && mode !== 'extension' && mode !== 'upgrade') {
|
||||
redirect('/order')
|
||||
}
|
||||
|
||||
@@ -78,7 +104,7 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
|
||||
// Admin sees all end customers across partners
|
||||
let endCustomers = []
|
||||
if (userData?.role === 'admin') {
|
||||
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
|
||||
try {
|
||||
const adminClient = createAdminClient()
|
||||
const { data } = await adminClient
|
||||
@@ -106,5 +132,29 @@ async function OrderDataWrapper({ orderId }: { orderId?: string }) {
|
||||
console.error('Fehler beim Laden des Profils:', e)
|
||||
}
|
||||
|
||||
return <OrderWizard products={products} categories={categories} initialProfile={profile} initialEndCustomers={endCustomers} initialOrder={initialOrder} />
|
||||
// Fetch companies list for admin
|
||||
let companies: any[] = []
|
||||
if (userData?.role === 'admin' || userData?.role === 'verwaltung') {
|
||||
companies = await getCompanies().catch(() => [])
|
||||
}
|
||||
|
||||
const initialLastLicenseDate = initialOrder
|
||||
? (initialOrder.order_data?.last_license_date || (initialOrder.created_at ? new Date(initialOrder.created_at).toISOString().split('T')[0] : ''))
|
||||
: ''
|
||||
|
||||
return (
|
||||
<OrderWizard
|
||||
products={products}
|
||||
categories={categories}
|
||||
initialProfile={profile}
|
||||
initialEndCustomers={endCustomers}
|
||||
initialOrder={initialOrder}
|
||||
isAdmin={userData?.role === 'admin' || userData?.role === 'verwaltung'}
|
||||
companies={companies}
|
||||
upgradeMode={mode === 'upgrade'}
|
||||
initialEndCustomerId={customerId || null}
|
||||
lockedDeviceId={deviceId ? decodeURIComponent(deviceId) : null}
|
||||
initialLastLicenseDate={initialLastLicenseDate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' || dbUser?.role === 'verwaltung'
|
||||
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 (
|
||||
@@ -77,23 +113,30 @@ export default async function OrderSuccessPage({
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Produkte */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
{/* Produkte gruppiert nach Kasse */}
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedItems).map(([deviceName, devItems]) => (
|
||||
<div key={deviceName} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
|
||||
<div className="bg-white/5 px-2 py-1 rounded text-xs text-primary font-bold">
|
||||
{deviceName === 'Zusatzleistung' ? 'Backoffice' : `Kasse: ${deviceName}`}
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
{devItems.map((item) => (
|
||||
<div key={item.product_id} className="space-y-1 pl-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-white">{item.product_name}</span>
|
||||
<span className="text-white">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
|
||||
<span className="text-slate-400 text-xs ml-1">
|
||||
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.selected_modules.map((mod) => (
|
||||
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
|
||||
<span>+ {mod.module_name}</span>
|
||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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-primary"}>
|
||||
{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-primary">
|
||||
{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">
|
||||
@@ -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 />
|
||||
}
|
||||
16
shop/app/wizard/page.tsx
Normal file
16
shop/app/wizard/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ id?: string; orderId?: string; mode?: string }>
|
||||
}
|
||||
|
||||
export default async function WizardPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const id = params.orderId || params.id
|
||||
const mode = params.mode
|
||||
const query = new URLSearchParams()
|
||||
if (id) query.set('orderId', id)
|
||||
if (mode) query.set('mode', mode)
|
||||
|
||||
redirect(`/order?${query.toString()}`)
|
||||
}
|
||||
180
shop/components/AsciiShaderBackground.tsx
Normal file
180
shop/components/AsciiShaderBackground.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let animationFrameId: number
|
||||
|
||||
// Minimalistisches, ruhiges ASCII-Array
|
||||
const chars = ['•', '∘', '+', '▪', '▫', ' ']
|
||||
const fontSize = 18
|
||||
let cols = 0
|
||||
let rows = 0
|
||||
|
||||
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }
|
||||
let scrollProgress = 0
|
||||
let targetScrollProgress = 0
|
||||
|
||||
const getDynamicHue = (): number => {
|
||||
try {
|
||||
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return Math.round(h * 360)
|
||||
} catch (e) {
|
||||
return 217
|
||||
}
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
cols = Math.ceil(width / fontSize)
|
||||
rows = Math.ceil(height / fontSize)
|
||||
|
||||
if (mouse.x === 0 && mouse.y === 0) {
|
||||
mouse.x = width / 2
|
||||
mouse.y = height / 2
|
||||
mouse.targetX = width / 2
|
||||
mouse.targetY = height / 2
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
mouse.targetX = e.clientX
|
||||
mouse.targetY = e.clientY
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const maxScroll = Math.max(
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
1
|
||||
)
|
||||
targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
const render = (time: number) => {
|
||||
mouse.x += (mouse.targetX - mouse.x) * 0.05
|
||||
mouse.y += (mouse.targetY - mouse.y) * 0.05
|
||||
scrollProgress += (targetScrollProgress - scrollProgress) * 0.05
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Beim Scrollen sofort ausblenden (vollständig weg ab 150px Scroll-Tiefe)
|
||||
const scrollFade = Math.max(1 - (window.scrollY / 150), 0)
|
||||
if (scrollFade <= 0) {
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const timeSec = time * 0.001
|
||||
const activeHue = getDynamicHue()
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * fontSize + fontSize / 2
|
||||
const y = r * fontSize + fontSize / 2
|
||||
|
||||
const dx = x - mouse.x
|
||||
const dy = y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
const mouseInfluence = Math.exp(-dist / 250)
|
||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
||||
|
||||
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
|
||||
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||
|
||||
const normalizedFlow = (flowValue + 1) * 0.5
|
||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||
const char = chars[charIndex]
|
||||
|
||||
if (char === ' ') continue
|
||||
|
||||
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
|
||||
const lightness = 40 + Math.round(blendFactor * 50)
|
||||
|
||||
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
|
||||
ctx.fillText(char, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full block will-change-transform pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-60"
|
||||
style={{
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface HeaderWrapperProps {
|
||||
navbar: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
@@ -11,26 +13,30 @@ 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}</>;
|
||||
}
|
||||
|
||||
const isWizard = pathname === "/order" || pathname === "/wizard";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white">
|
||||
<div className={`flex flex-col ${isWizard ? "h-screen overflow-hidden" : "min-h-screen"} bg-slate-50 text-slate-900 dark:bg-[#020617] dark:text-white`}>
|
||||
{/* Demo Banner */}
|
||||
<DemoWrapper>
|
||||
<div
|
||||
id="demo-banner"
|
||||
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50"
|
||||
className="bg-amber-500/10 border-b border-amber-500/20 py-2 px-4 text-center text-xs text-amber-600 dark:text-amber-400 font-medium tracking-wide z-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
⚠️ Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Dies ist eine **Demo-Webseite (Dummy-Shop)**. Es werden keine echten Bestellungen verarbeitet oder Zahlungen abgewickelt.</span>
|
||||
</div>
|
||||
</DemoWrapper>
|
||||
|
||||
{navbar}
|
||||
|
||||
<main className="flex-1">
|
||||
<main className={`flex-1 ${isWizard ? "overflow-hidden" : ""}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,8 @@ import { type User } from '@supabase/supabase-js'
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ShoppingCart,
|
||||
Utensils,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Layers,
|
||||
LifeBuoy,
|
||||
Users,
|
||||
@@ -18,17 +18,34 @@ import {
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
ShieldCheck,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Activity
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ProcessSteps } from '@/components/ProcessSteps'
|
||||
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
||||
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
||||
import { useTheme } from '@/components/ThemeProvider'
|
||||
|
||||
interface HomeClientProps {
|
||||
initialUser: User | null
|
||||
}
|
||||
|
||||
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
const { branding } = useTheme()
|
||||
const [user, setUser] = useState<User | null>(initialUser)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -68,39 +85,10 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
<div className="min-h-screen bg-transparent text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
|
||||
<AsciiShaderBackground />
|
||||
|
||||
|
||||
{/* Background Decorative Animated Blobs */}
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.15, 0.95, 1],
|
||||
rotate: [0, 90, 180, 270, 360],
|
||||
x: [0, 40, -30, 0],
|
||||
y: [0, -20, 50, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 0.9, 1.1, 1],
|
||||
rotate: [0, -90, -180, -270, -360],
|
||||
x: [0, -30, 40, 0],
|
||||
y: [0, 50, -30, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 25,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
|
||||
@@ -110,34 +98,20 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
variants={containerVariants}
|
||||
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
|
||||
>
|
||||
{/* Badge */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
|
||||
</motion.div>
|
||||
CASPOS Lizenz-Portal
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<motion.h1
|
||||
variants={itemVariants}
|
||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
|
||||
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight text-white"
|
||||
>
|
||||
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
|
||||
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>
|
||||
|
||||
@@ -147,8 +121,8 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
|
||||
>
|
||||
<Link href="/order" className="w-full sm:w-auto">
|
||||
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
|
||||
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">
|
||||
@@ -164,51 +138,71 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
variants={itemVariants}
|
||||
className="w-full max-w-3xl pt-12"
|
||||
>
|
||||
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
|
||||
{/* Card border glow decoration */}
|
||||
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
|
||||
Mein Dashboard Schnellzugriff
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Link href="/my-customers" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
<Users className="w-5.5 h-5.5" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Kundenliste</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Kunden & Lizenzen verwalten</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Verwalten</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
<Link href="/my-orders" className="group">
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
|
||||
<ClipboardList className="w-5.5 h-5.5" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Bestellungen</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Aufträge & Status einsehen</span>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Status einsehen</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4.5 h-4.5 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all" />
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
<button type="button" onClick={() => setCalendarOpen(true)} className="group text-left w-full">
|
||||
<motion.div
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200 h-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors shrink-0">
|
||||
<Calendar className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="block font-bold text-sm text-slate-200 group-hover:text-white transition-colors">Termine</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-md">Geplant</span>
|
||||
</div>
|
||||
<span className="block text-xs text-slate-500 group-hover:text-slate-400 transition-colors">Terminkalender</span>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-slate-600 group-hover:text-blue-400 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-all shrink-0" />
|
||||
</motion.div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -217,181 +211,65 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Grid of modules */}
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/50 relative">
|
||||
<div className="container max-w-6xl mx-auto px-4 relative">
|
||||
|
||||
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
|
||||
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
|
||||
<Sparkles className="w-3 h-3" />CASPOS Sites
|
||||
{/* Dialog / Popup für Termin-Kalender (Geplant) */}
|
||||
<Dialog open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 text-slate-100 max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<Calendar className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||
Termin-Kalender
|
||||
<span className="px-2 py-0.5 text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-full">
|
||||
In Planung
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
|
||||
Workspace-Zentrale
|
||||
</h2>
|
||||
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
|
||||
Die zentralen Plattformen für unsere tägliche Zusammenarbeit, Dokumentation und den Support.
|
||||
</p>
|
||||
<DialogDescription className="text-slate-400 text-sm leading-relaxed pt-2">
|
||||
Der Termin-Kalender bietet in Kürze eine Übersicht für anstehende Schulungen, Online- & Vor-Ort-Workshops sowie Termine für Blauer-Mittwoch-Partner zur direkten Anmeldung.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-4 my-2 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-400 space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-300 font-medium">
|
||||
<span>Geplante Features & Angebote:</span>
|
||||
</div>
|
||||
<ul className="list-disc list-inside space-y-1 text-slate-400">
|
||||
<li>Anmeldung zu Online- & Vor-Ort-Workshops</li>
|
||||
<li>Anstehende Schulungstermine auf einen Blick</li>
|
||||
<li>Spezielle Schulungen für Blauer-Mittwoch-Partner</li>
|
||||
<li>Kalender-Export (.ics / Google / Outlook)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Card 1 */}
|
||||
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCalendarOpen(false)}
|
||||
className="w-full sm:w-auto border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Zulip</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Treten Sie unserem Partner-Chat bei! tauschen Sie sich mit anderen CASPOS Partnern aus.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Card 2 */}
|
||||
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<Utensils className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Wiki</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Ihr Wissens-Portal für die CASPOS Produktfamilie.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
|
||||
{/* Card 3 */}
|
||||
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
|
||||
whileHover={{ y: -6 }}
|
||||
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Highlight line hover effect */}
|
||||
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
<div className="space-y-4 relative">
|
||||
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
|
||||
<LifeBuoy className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-200 group-hover:text-white transition-colors">Support</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
Sie haben ein technisches Problem in der CASPOS? Erstellen Sie jetzt ein Ticket!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/* Grid of modules / Workspace-Zentrale */}
|
||||
<WorkspaceZentrale />
|
||||
|
||||
{/* Stepper info */}
|
||||
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
|
||||
<div className="container max-w-5xl mx-auto px-4">
|
||||
<div className="text-center space-y-3 mb-16">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
|
||||
<Activity className="w-3 h-3" /> Ablauf
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
|
||||
Wie funktioniert die Bereitstellung?
|
||||
</h2>
|
||||
</div>
|
||||
<ProcessSteps />
|
||||
|
||||
<div className="relative">
|
||||
{/* Connection line for Desktop */}
|
||||
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 relative z-10">
|
||||
{/* Step 1 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
1
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Kunde wählen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Gewünschten Kunden auswählen, der die Lizenz erhalten soll.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
2
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Lizenz wählen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Gewünschte Lizenzen für den Kunden auswählen und den Bestellung abschließen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="flex flex-col items-center text-center space-y-4 p-4 group">
|
||||
<div className="flex items-center justify-center w-14 h-14 rounded-2xl bg-slate-900 border border-slate-800 text-blue-400 font-black text-lg shadow-lg group-hover:border-blue-500/40 group-hover:shadow-[0_0_15px_rgba(59,130,246,0.15)] transition-all duration-300">
|
||||
3
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-200 group-hover:text-white transition-colors">Live gehen</h4>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-[250px]">
|
||||
Nach dem Erhalt der Lizenz Live gehen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
|
||||
</p>
|
||||
<nav className="flex gap-6">
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface NavbarClientProps {
|
||||
user: User | null;
|
||||
@@ -18,6 +19,7 @@ interface NavbarClientProps {
|
||||
|
||||
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
const router = useRouter();
|
||||
const { branding } = useTheme();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||
const [userRole, setUserRole] = useState<string>(role);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<>
|
||||
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
{branding?.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
|
||||
) : branding?.companyName ? (
|
||||
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
|
||||
{branding.companyName}
|
||||
</span>
|
||||
) : (
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
)}
|
||||
<DemoWrapper>
|
||||
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||
Demo
|
||||
@@ -110,7 +120,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<nav className="hidden md:flex gap-6 items-center">
|
||||
<Link
|
||||
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
|
||||
href="https://wiki.caspos.de"
|
||||
href="https://support.teamzone.softengine.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -136,17 +146,20 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<div className="h-4 w-px bg-white/10 mx-2" />
|
||||
|
||||
{currentUser ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="border-white/10 hover:bg-white/5 gap-2 text-white"
|
||||
>
|
||||
<UserIcon className="w-4 h-4 text-primary" />
|
||||
<span className="max-w-[120px] truncate">{currentUser.email}</span>
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10">
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-xl border p-2 shadow-xl z-50 bg-white text-slate-900 border-slate-200 dark:bg-slate-950 dark:text-white dark:border-white/10 before:absolute before:-top-3 before:left-0 before:w-full before:h-3">
|
||||
<div className="px-3 py-1.5 text-[11px] truncate border-b text-slate-400 border-slate-100 dark:border-white/5 mb-1">
|
||||
{currentUser.email}
|
||||
</div>
|
||||
@@ -197,7 +210,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild className="bg-primary hover:bg-primary/95 text-white dark:text-blue-500">
|
||||
<Button asChild className="bg-primary hover:bg-primary/95 text-white">
|
||||
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -287,7 +300,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<LogOut className="w-4 h-4" /> Abmelden
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white dark:text-blue-500" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button asChild className="w-full bg-primary hover:bg-primary/95 text-white" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Link href={`/auth/login?next=${pathname}`}>Anmelden</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
155
shop/components/ProcessSteps.tsx
Normal file
155
shop/components/ProcessSteps.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface Step {
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Kunde wählen',
|
||||
description: 'Gewünschten Kunden auswählen, der die Lizenz erhalten soll.'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Lizenz wählen',
|
||||
description: 'Gewünschte Lizenzen für den Kunden auswählen und die Bestellung abschließen.'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Live gehen',
|
||||
description: 'Nach dem Erhalt der Lizenz direkt live gehen und durchstarten.'
|
||||
}
|
||||
]
|
||||
|
||||
const containerVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.2,
|
||||
staggerChildren: 0.35
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stepVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: 'spring' as const,
|
||||
stiffness: 70,
|
||||
damping: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ProcessSteps() {
|
||||
return (
|
||||
<section className="w-full py-16 px-4 relative z-10">
|
||||
<div className="container max-w-5xl mx-auto flex flex-col items-center">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ type: 'spring' as const, stiffness: 60, damping: 15 }}
|
||||
className="text-center space-y-3 mb-16 max-w-2xl"
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
|
||||
Wie funktioniert die Bereitstellung?
|
||||
</h2>
|
||||
<p className="text-slate-400 text-base sm:text-lg leading-relaxed">
|
||||
In nur drei einfachen Schritten richten Sie CASPOS Lizenzen für Ihre Kunden ein.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Horizontale Timeline */}
|
||||
<div className="relative w-full">
|
||||
{/* Basis-Linie exakt zwischen Schritt 1 und 3 */}
|
||||
<div className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-white/10 z-0 rounded-full" />
|
||||
|
||||
{/* Animierte, aufgeladene Linie (sky-500) */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
whileInView={{ scaleX: 1 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2
|
||||
}}
|
||||
className="hidden md:block absolute top-6 left-[16.6%] right-[16.6%] h-[2px] bg-sky-500 origin-left z-0 rounded-full shadow-[0_0_12px_rgba(14,165,233,0.8)]"
|
||||
/>
|
||||
|
||||
{/* 3 Schritte Nebeneinander */}
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6 w-full relative z-10"
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const isLiveStep = index === 2
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={step.number}
|
||||
variants={stepVariants}
|
||||
className="group flex flex-col items-center text-center space-y-4 cursor-pointer relative"
|
||||
>
|
||||
{/* Nummer im runden Kreis auf der Linie */}
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.15 }}
|
||||
transition={{ type: 'spring' as const, stiffness: 300, damping: 20 }}
|
||||
className="w-12 h-12 rounded-full bg-slate-950 border-2 border-slate-700 text-slate-300 font-extrabold text-lg flex items-center justify-center group-hover:border-sky-400 group-hover:text-sky-400 group-hover:bg-slate-900 group-hover:shadow-[0_0_20px_rgba(56,189,248,0.4)] transition-all duration-300 z-10 relative"
|
||||
>
|
||||
{step.number}
|
||||
</motion.div>
|
||||
|
||||
{/* Pulsierender Ziel-Ring bei Schritt 3 */}
|
||||
{isLiveStep && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 1.6 }}
|
||||
className="absolute inset-0 rounded-full border-2 border-emerald-400/60 pointer-events-none animate-ping"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text-Inhalt zentriert ohne Boxen-Hintergrund */}
|
||||
<div className="space-y-2 max-w-[280px]">
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-sky-400 transition-colors duration-300 flex items-center justify-center gap-2">
|
||||
{step.title}
|
||||
{isLiveStep && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
24
shop/components/ScrollIndicator.tsx
Normal file
24
shop/components/ScrollIndicator.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { motion, useScroll, useSpring } from 'framer-motion'
|
||||
|
||||
export function ScrollIndicator() {
|
||||
const { scrollYProgress } = useScroll()
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
|
||||
style={{
|
||||
scaleX,
|
||||
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
|
||||
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
679
shop/components/SetupWizard.tsx
Normal file
679
shop/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
'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, MapPin, Receipt, Palette, Send } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
|
||||
|
||||
export function SetupWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
|
||||
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Step 2: Admin Account
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
// Step 3: Firmendaten & Rechnungsadresse
|
||||
const [brandingForm, setBrandingForm] = useState({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
})
|
||||
|
||||
// Step 5: SMTP Config
|
||||
const [smtpForm, setSmtpForm] = useState({
|
||||
host: '',
|
||||
port: '587',
|
||||
secure: false,
|
||||
user: '',
|
||||
pass: '',
|
||||
})
|
||||
|
||||
// Validations
|
||||
const isAdminFormValid =
|
||||
adminForm.email.includes('@') &&
|
||||
adminForm.password.length >= 6 &&
|
||||
adminForm.password === adminForm.confirmPassword &&
|
||||
adminForm.firstName.trim().length > 0 &&
|
||||
adminForm.lastName.trim().length > 0
|
||||
|
||||
const isCompanyFormValid =
|
||||
brandingForm.companyName.trim().length > 0 &&
|
||||
brandingForm.street.trim().length > 0 &&
|
||||
brandingForm.zip.trim().length > 0 &&
|
||||
brandingForm.city.trim().length > 0 &&
|
||||
(brandingForm.sameBillingAddress ||
|
||||
(brandingForm.billingStreet.trim().length > 0 &&
|
||||
brandingForm.billingZip.trim().length > 0 &&
|
||||
brandingForm.billingCity.trim().length > 0))
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTestSmtpLoading(true)
|
||||
setTestSmtpResult(null)
|
||||
const res = await testSmtpConfig(
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
},
|
||||
adminForm.email
|
||||
)
|
||||
setTestSmtpResult(res)
|
||||
setTestSmtpLoading(false)
|
||||
}
|
||||
|
||||
const handleFinishSetup = async () => {
|
||||
if (!isAdminFormValid || !isCompanyFormValid) return
|
||||
setLoading(true)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
const res = await completeSetup(
|
||||
{
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
companyName: brandingForm.companyName,
|
||||
firstName: adminForm.firstName,
|
||||
lastName: adminForm.lastName,
|
||||
},
|
||||
{
|
||||
street: brandingForm.street,
|
||||
zip: brandingForm.zip,
|
||||
city: brandingForm.city,
|
||||
billingStreet: brandingForm.billingStreet,
|
||||
billingZip: brandingForm.billingZip,
|
||||
billingCity: brandingForm.billingCity,
|
||||
sameBillingAddress: brandingForm.sameBillingAddress,
|
||||
colorScheme: brandingForm.colorScheme,
|
||||
primaryColor: brandingForm.primaryColor,
|
||||
accentColor: brandingForm.accentColor,
|
||||
},
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
}
|
||||
)
|
||||
|
||||
if (!res.success) {
|
||||
setErrorMsg(res.error || 'Fehler beim Setup.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Setup erfolgreich – Weiterleitung zur Login-Seite
|
||||
router.push('/auth/login?setup=success')
|
||||
router.refresh()
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* 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-2xl relative z-10 my-8">
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-6 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">B2B Shop</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">made by hephex</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* STEP 1: Willkommen */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
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">B2B Shop made by hephex</h1>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Lizenzserver-Anbindung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Stammdatenverwaltung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Adress- & Rechnungsverwaltung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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: Admin-Konto */}
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="w-6 h-6 text-blue-400" />
|
||||
Admin-Konto anlegen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
|
||||
</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">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 pt-2">
|
||||
<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 Firmendaten
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Firmendaten & Rechnungsadresse */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Building className="w-6 h-6 text-blue-400" />
|
||||
Firmendaten & Adressen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Firmenname *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={brandingForm.companyName}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
|
||||
placeholder="z. B. CASPOS Software GmbH"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anschrift */}
|
||||
<div className="space-y-3 pt-1 border-t border-slate-800">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
|
||||
</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
|
||||
<Input
|
||||
value={brandingForm.street}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.zip}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.city}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechnungsadresse */}
|
||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingForm.sameBillingAddress}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Anschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!brandingForm.sameBillingAddress && (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingStreet}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingZip}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
|
||||
}
|
||||
placeholder="54321"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingCity}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(4)}
|
||||
disabled={!isCompanyFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zum Farbschema
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Farbschema & Live-Vorschau */}
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step4"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Palette className="w-6 h-6 text-blue-400" />
|
||||
Farbschema & Webshop Styling
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ColorThemePicker
|
||||
colorScheme={brandingForm.colorScheme}
|
||||
primaryColor={brandingForm.primaryColor}
|
||||
accentColor={brandingForm.accentColor}
|
||||
companyName={brandingForm.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBrandingForm({
|
||||
...brandingForm,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu SMTP
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 5: SMTP Mailserver */}
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step5"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver
|
||||
</h2>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
|
||||
</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 justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
checked={smtpForm.secure}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
|
||||
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
|
||||
>
|
||||
{testSmtpLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testSmtpResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-xs font-medium ${
|
||||
testSmtpResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testSmtpResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(4)}
|
||||
disabled={loading}
|
||||
className="text-white hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFinishSetup}
|
||||
disabled={loading}
|
||||
className="border-slate-700 text-slate-300 hover:text-white"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinishSetup}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Setup wird abgeschlossen...
|
||||
</>
|
||||
) : (
|
||||
'Einrichtung abschließen'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
shop/components/ThemeProvider.tsx
Normal file
111
shop/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { getBrandingSettings } from '@/lib/actions/branding'
|
||||
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
try {
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0, s = 0, l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
||||
} catch (e) {
|
||||
return '217 91% 60%'
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
branding: BrandingSettings | null
|
||||
refreshBranding: () => Promise<void>
|
||||
}>({
|
||||
branding: null,
|
||||
refreshBranding: async () => {},
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
[key: string]: any
|
||||
}) {
|
||||
const [branding, setBranding] = useState<BrandingSettings | null>(null)
|
||||
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
const settings = await getBrandingSettings()
|
||||
setBranding(settings)
|
||||
|
||||
// Preset Ermittlung
|
||||
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
const isCustom = settings.colorScheme === 'custom'
|
||||
|
||||
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
|
||||
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
|
||||
|
||||
const primaryHsl = hexToHsl(primary)
|
||||
const accentHsl = hexToHsl(accent)
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// 10 Color CSS Tokens applied globally onto :root
|
||||
root.style.setProperty('--primary', primaryHsl)
|
||||
root.style.setProperty('--ring', primaryHsl)
|
||||
root.style.setProperty('--accent', accentHsl)
|
||||
|
||||
root.style.setProperty('--primary-custom', primary)
|
||||
root.style.setProperty('--accent-custom', accent)
|
||||
|
||||
root.style.setProperty('--success-custom', settings.successColor || preset.success)
|
||||
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
|
||||
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
|
||||
|
||||
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
|
||||
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
|
||||
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
|
||||
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
|
||||
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
|
||||
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
|
||||
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
|
||||
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
|
||||
} catch (e) {
|
||||
console.error('Failed to load branding theme:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTheme()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider {...props}>
|
||||
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
132
shop/components/WorkspaceZentrale.tsx
Normal file
132
shop/components/WorkspaceZentrale.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MessageSquare, BookOpen, LifeBuoy, ExternalLink } from 'lucide-react'
|
||||
|
||||
interface WorkspaceItem {
|
||||
id: string
|
||||
title: string
|
||||
subtitle: string
|
||||
hoverText: string
|
||||
icon: React.ElementType
|
||||
link: string
|
||||
}
|
||||
|
||||
const workspaceItems: WorkspaceItem[] = [
|
||||
{
|
||||
id: 'zulip',
|
||||
title: 'Zulip Chat',
|
||||
subtitle: 'Treten Sie unserem Partner-Chat bei!',
|
||||
hoverText: 'Tauschen Sie sich direkt und unkompliziert mit anderen CASPOS Partnern aus.',
|
||||
icon: MessageSquare,
|
||||
link: 'https://caspos.zulipchat.com'
|
||||
},
|
||||
{
|
||||
id: 'wiki',
|
||||
title: 'CASPOS Wiki',
|
||||
subtitle: 'Ihr Wissens-Portal...',
|
||||
hoverText: '...für die gesamte CASPOS Produktfamilie. Hier finden Sie alle technischen Dokumentationen.',
|
||||
icon: BookOpen,
|
||||
link: 'https://wiki.caspos.de'
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
title: 'Support Portal',
|
||||
subtitle: 'Sie haben ein technisches Problem?',
|
||||
hoverText: 'Erstellen Sie jetzt ein Ticket in der CASPOS, damit sich unsere Experten darum kümmern.',
|
||||
icon: LifeBuoy,
|
||||
link: 'https://support.caspos.de'
|
||||
}
|
||||
]
|
||||
|
||||
export function WorkspaceZentrale() {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<section className="w-full py-12 px-4 relative z-10">
|
||||
<div className="container max-w-6xl mx-auto space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
||||
Workspace Zentrale
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 3-Spalten Grid mit fester Kartenhöhe */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{workspaceItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isHovered = hoveredId === item.id
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
key={item.id}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ y: -4 }}
|
||||
onMouseEnter={() => setHoveredId(item.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className={`group relative p-6 rounded-2xl border transition-all duration-300 backdrop-blur-md flex flex-col justify-between h-[210px] overflow-hidden cursor-pointer ${
|
||||
isHovered
|
||||
? 'border-sky-500 bg-slate-900/90 shadow-[0_0_25px_rgba(14,165,233,0.2)]'
|
||||
: 'border-slate-800 bg-slate-900/60'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Header: Icon & Link */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={`p-3 rounded-xl border transition-all duration-300 ${
|
||||
isHovered
|
||||
? 'bg-sky-500/20 border-sky-500/50 text-sky-300'
|
||||
: 'bg-slate-800/60 border-slate-700/50 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<ExternalLink
|
||||
className={`w-4 h-4 transition-all duration-300 ${
|
||||
isHovered
|
||||
? 'text-sky-400 translate-x-0.5 -translate-y-0.5'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-bold text-white group-hover:text-sky-300 transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{/* Subtitle / Hover-Text Überblendung ohne Höhenänderung */}
|
||||
<div className="relative h-[48px] overflow-hidden">
|
||||
<p
|
||||
className={`text-sm text-slate-300 font-medium transition-all duration-300 absolute inset-0 ${
|
||||
isHovered ? 'opacity-0 translate-y-[-8px]' : 'opacity-100 translate-y-0'
|
||||
}`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs text-slate-400 leading-relaxed transition-all duration-300 absolute inset-0 ${
|
||||
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-[8px]'
|
||||
}`}
|
||||
>
|
||||
{item.hoverText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtile Hover-Linie unten */}
|
||||
<div className={`w-full h-0.5 rounded-full transition-all duration-300 ${
|
||||
isHovered ? 'bg-sky-500/80 shadow-[0_0_8px_rgba(14,165,233,0.8)]' : 'bg-transparent'
|
||||
}`} />
|
||||
</motion.a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
267
shop/components/admin/ColorThemePicker.tsx
Normal file
267
shop/components/admin/ColorThemePicker.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
|
||||
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface ColorThemePickerProps {
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
companyName?: string
|
||||
onChange: (scheme: string, primary: string, accent: string) => void
|
||||
}
|
||||
|
||||
export function ColorThemePicker({
|
||||
colorScheme,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
companyName = 'CASPOS Shop',
|
||||
onChange,
|
||||
}: ColorThemePickerProps) {
|
||||
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
|
||||
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
|
||||
|
||||
const isCustom = colorScheme === 'custom'
|
||||
|
||||
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
|
||||
const handleSelectPreset = (preset: ColorPreset) => {
|
||||
onChange(preset.id, preset.primary, preset.accent)
|
||||
}
|
||||
|
||||
const handleCustomPrimaryChange = (val: string) => {
|
||||
setCustomPrimary(val)
|
||||
onChange('custom', val, customAccent)
|
||||
}
|
||||
|
||||
const handleCustomAccentChange = (val: string) => {
|
||||
setCustomAccent(val)
|
||||
onChange('custom', customPrimary, val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grid of Presets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-blue-400" />
|
||||
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PRESET_COLOR_SCHEMES.map((preset) => {
|
||||
const isSelected = colorScheme === preset.id
|
||||
|
||||
const tokenSwatches = [
|
||||
{ name: 'Primary', color: preset.primary, desc: 'Helle/edle Hauptfarbe für Navigation/Texte' },
|
||||
{ name: 'Accent', color: preset.accent, desc: 'Scharfe Highlight-Farbe für Badges/Meldungen' },
|
||||
{ name: 'bgGlow1', color: preset.bgGlow1, desc: 'Atmosphärischer Lichtnebel von oben' },
|
||||
{ name: 'bgGlow2', color: preset.bgGlow2, desc: 'Subtiles Gegenlicht von unten' },
|
||||
{ name: 'gradientFrom', color: preset.gradientFrom, desc: 'Startfarbe für Core-Verläufe' },
|
||||
{ name: 'gradientTo', color: preset.gradientTo, desc: 'Endfarbe für Core-Verläufe' },
|
||||
{ name: 'textHighlight', color: preset.textHighlight, desc: 'Soft-Pastell Text-Empfehlung' },
|
||||
{ name: 'buttonBg', color: preset.buttonBg, desc: 'Satte Basisfarbe Hauptbuttons' },
|
||||
{ name: 'ringColor', color: preset.ringColor, desc: 'Präzise Fokus-Rand-Farbe' },
|
||||
{ name: 'cardBorder', color: preset.cardBorder, desc: 'Subtiler Rahmen-Ton Karten' },
|
||||
]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
|
||||
isSelected
|
||||
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-xs text-white truncate pr-2">
|
||||
{preset.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* 10 Color Token Swatches Bar */}
|
||||
<div className="space-y-1 pt-1 border-t border-slate-800/60">
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
{tokenSwatches.map((item, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0 transition-transform hover:scale-125"
|
||||
style={{ backgroundColor: item.color }}
|
||||
title={`${item.name}: ${item.desc} (${item.color})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Picker */}
|
||||
<div
|
||||
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
|
||||
isCustom
|
||||
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/40 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('custom', customPrimary, customAccent)}
|
||||
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
|
||||
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
|
||||
}`}>
|
||||
{isCustom && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
|
||||
{/* Primary Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Micro-Preview Card with Animated Background Orbs */}
|
||||
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
|
||||
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
Live Vorschau & Animierter Background Glow
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">
|
||||
{primaryColor} / {accentColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
|
||||
{/* Animated Background Orbs Preview */}
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: 'var(--bg-glow-1, ' + (activePreset.bgGlow1 || primaryColor) + ')' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: 'var(--bg-glow-2, ' + (activePreset.bgGlow2 || accentColor) + ')' }}
|
||||
/>
|
||||
|
||||
{/* Header Preview */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
|
||||
style={{ backgroundColor: 'var(--primary-custom, ' + primaryColor + ')' }}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="font-bold text-xs text-white">
|
||||
{companyName || 'Ihr Unternehmen'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
|
||||
style={{ backgroundColor: 'var(--accent-custom, ' + accentColor + ')' }}
|
||||
>
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons & Badges Preview */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, var(--gradient-from, ${primaryColor}) 0%, var(--gradient-to, ${activePreset.gradientTo || primaryColor}) 100%)`,
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
In den Warenkorb
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
|
||||
style={{ borderColor: 'var(--accent-custom, ' + accentColor + ')' }}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
|
||||
style={{
|
||||
borderColor: 'var(--card-border-glow, ' + accentColor + '40)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent-custom, ' + accentColor + ') 15%, transparent)',
|
||||
color: 'var(--text-highlight, ' + (activePreset.textHighlight || accentColor) + ')',
|
||||
}}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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">
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Plus, Trash2, X, PlusCircle } from 'lucide-react'
|
||||
import { Plus, Trash2, X, PlusCircle, CreditCard, RefreshCw } from 'lucide-react'
|
||||
import { createProduct, updateProduct } from '@/lib/actions/products'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -61,6 +61,7 @@ const productSchema = z.object({
|
||||
requirements: z.array(z.string()).default([]),
|
||||
exclusions: z.array(z.string()).default([]),
|
||||
modules: z.array(moduleSchema).default([]),
|
||||
allow_update_discount: z.boolean().default(true),
|
||||
})
|
||||
|
||||
type ProductFormValues = z.infer<typeof productSchema>
|
||||
@@ -77,8 +78,32 @@ export function CreateProductDialog({
|
||||
products?: Product[]
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const router = useRouter()
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
setOpen(isOpen)
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
let isValid = false
|
||||
if (step === 1) {
|
||||
isValid = await form.trigger(['name', 'category_id', 'base_price'])
|
||||
} else if (step === 2) {
|
||||
isValid = await form.trigger('billing_interval')
|
||||
} else if (step === 3) {
|
||||
isValid = await form.trigger(['show_in_kauf', 'show_in_abo'])
|
||||
} else if (step === 4) {
|
||||
isValid = await form.trigger(['requirements', 'exclusions'])
|
||||
}
|
||||
if (isValid || step === 5) {
|
||||
setStep(prev => Math.min(prev + 1, 5))
|
||||
}
|
||||
}
|
||||
|
||||
const otherProducts = products.filter(p => p.id !== product?.id)
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
@@ -93,6 +118,7 @@ export function CreateProductDialog({
|
||||
show_in_abo: product ? (product.show_in_abo ?? true) : true,
|
||||
requirements: product?.requirements || [],
|
||||
exclusions: product?.exclusions || [],
|
||||
allow_update_discount: product ? (product.allow_update_discount ?? true) : true,
|
||||
modules: product?.modules?.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -134,7 +160,7 @@ export function CreateProductDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button className="bg-primary hover:bg-primary/90">
|
||||
@@ -142,7 +168,7 @@ export function CreateProductDialog({
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
|
||||
@@ -155,142 +181,212 @@ export function CreateProductDialog({
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-hidden flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto pr-4 space-y-6 py-4 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Step Indicator */}
|
||||
{!product && (
|
||||
<div className="flex justify-between items-center mb-6 border-b border-slate-100 dark:border-white/5 pb-4">
|
||||
{[
|
||||
{ nr: 1, label: 'Allgemein' },
|
||||
{ nr: 2, label: 'Abrechnung' },
|
||||
{ nr: 3, label: 'Sichtbarkeit' },
|
||||
{ nr: 4, label: 'Abhängigkeiten' },
|
||||
{ nr: 5, label: 'Module' }
|
||||
].map(s => (
|
||||
<div key={s.nr} className="flex flex-col items-center gap-1 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
|
||||
step === s.nr
|
||||
? 'bg-primary text-white'
|
||||
: step > s.nr
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
{s.nr}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(product || step === 1) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Basispreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
name="category_id"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
<SelectValue placeholder="Kategorie wählen" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 2) && (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="billing_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
{[
|
||||
{ value: 'one_time', label: 'Einmalig', icon: CreditCard },
|
||||
{ value: 'monthly', label: 'Monatlich', icon: RefreshCw },
|
||||
].map(opt => {
|
||||
const IconComponent = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => field.onChange(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
field.value === opt.value
|
||||
? 'border-primary bg-primary/10 text-primary dark:text-white'
|
||||
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-5 h-5 mb-1" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 3) && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_kauf"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="show_in_abo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.watch('billing_interval') === 'one_time' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allow_update_discount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Update-Rabatt erlauben</FormLabel>
|
||||
<FormDescription className="text-xs text-slate-500">
|
||||
Kunden erhalten für dieses Produkt den selektiven Update-Rabatt.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 4) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Product Requirements */}
|
||||
<FormField
|
||||
@@ -384,33 +480,17 @@ export function CreateProductDialog({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="bg-slate-200 dark:bg-white/10" />
|
||||
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
|
||||
|
||||
{(product || step === 5) && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<PlusCircle className="w-5 h-5 text-primary" />
|
||||
Module
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => {
|
||||
@@ -423,79 +503,90 @@ export function CreateProductDialog({
|
||||
.filter((_, idx) => idx !== index)
|
||||
|
||||
return (
|
||||
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Cloud Storage" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white">Aufpreis (€)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div key={field.id} className="p-4 border border-slate-200 dark:border-white/10 rounded-xl bg-slate-50 dark:bg-white/5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Modulname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="z.B. Zusatzlizenz" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.price`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Preis (€/Monat)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20 mt-6"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
|
||||
{/* Requirements Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: reqField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs">Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kurze Beschreibung" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{otherModules.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.requirements`}
|
||||
render={({ field: mReqField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Erfordert Module</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (reqField.value || []).includes(other.id)
|
||||
const checked = (mReqField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`req-${index}-${other.id}`}
|
||||
id={`mod-req-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = reqField.value || []
|
||||
const currentVal = mReqField.value || []
|
||||
if (checkedState) {
|
||||
reqField.onChange([...currentVal, other.id])
|
||||
mReqField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
reqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mReqField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`req-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-req-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -503,44 +594,38 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Exclusions Selection Matrix */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: exclField }) => (
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
|
||||
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
|
||||
{otherModules.length === 0 ? (
|
||||
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`modules.${index}.exclusions`}
|
||||
render={({ field: mExclField }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Schließt Module aus</FormLabel>
|
||||
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1">
|
||||
{otherModules.map(other => {
|
||||
const checked = (exclField.value || []).includes(other.id)
|
||||
const checked = (mExclField.value || []).includes(other.id)
|
||||
return (
|
||||
<div key={other.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`excl-${index}-${other.id}`}
|
||||
id={`mod-excl-${index}-${other.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(checkedState) => {
|
||||
const currentVal = exclField.value || []
|
||||
const currentVal = mExclField.value || []
|
||||
if (checkedState) {
|
||||
exclField.onChange([...currentVal, other.id])
|
||||
mExclField.onChange([...currentVal, other.id])
|
||||
} else {
|
||||
exclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
mExclField.onChange(currentVal.filter(id => id !== other.id))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`excl-${index}-${other.id}`}
|
||||
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
|
||||
title={other.name}
|
||||
htmlFor={`mod-excl-${index}-${other.id}`}
|
||||
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
|
||||
>
|
||||
{other.name}
|
||||
</label>
|
||||
@@ -548,12 +633,12 @@ export function CreateProductDialog({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
@@ -591,18 +676,73 @@ export function CreateProductDialog({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="text-center py-6 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-lg text-slate-500 dark:text-slate-400 text-sm">
|
||||
Noch keine Module hinzugefügt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-primary/50 text-primary hover:bg-primary/10"
|
||||
onClick={() => append({
|
||||
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
|
||||
name: '',
|
||||
price: 0,
|
||||
is_required: false,
|
||||
has_quantity: false,
|
||||
description: '',
|
||||
requirements: [],
|
||||
exclusions: []
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
|
||||
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10 flex justify-between items-center sm:justify-between">
|
||||
{product ? (
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="outline" onClick={() => setStep(prev => prev - 1)}>
|
||||
Zurück
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step < 5 ? (
|
||||
<Button type="button" onClick={nextStep} className="bg-primary hover:bg-primary/90 text-black font-medium">
|
||||
Weiter
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
|
||||
Speichern
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -5,14 +5,42 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Download, ExternalLink, Search, Loader2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Loader2,
|
||||
Eye,
|
||||
ArrowUpDown,
|
||||
Building2,
|
||||
User,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
FileText,
|
||||
Calendar,
|
||||
Layers,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
CheckCircle,
|
||||
Edit3
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { updateOrderStatus } from "@/lib/actions/orders";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { updateOrderStatus, rejectOrder, updateOrderPayload } from "@/lib/actions/orders";
|
||||
|
||||
interface OrdersTableProps {
|
||||
initialOrders: any[];
|
||||
@@ -20,33 +48,158 @@ interface OrdersTableProps {
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
pending_approval: 'Wartet auf Freigabe',
|
||||
in_review: 'In Prüfung',
|
||||
approved: 'Freigegeben',
|
||||
active: 'Aktiviert',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Storniert',
|
||||
rejected: 'Abgelehnt',
|
||||
};
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border border-amber-500/30',
|
||||
pending_approval: 'bg-amber-600/20 text-amber-300 border border-amber-500/40',
|
||||
in_review: 'bg-purple-500/20 text-purple-400 border border-purple-500/30',
|
||||
approved: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
|
||||
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
cancelled: 'bg-slate-500/20 text-slate-400 border border-slate-500/30',
|
||||
rejected: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
};
|
||||
|
||||
type SortField = 'date_desc' | 'date_asc' | 'price_desc' | 'price_asc' | 'order_num';
|
||||
|
||||
export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
const [orders, setOrders] = useState(initialOrders);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<SortField>("date_desc");
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
|
||||
|
||||
// Rejection Dialog State
|
||||
const [rejectingOrder, setRejectingOrder] = useState<any | null>(null);
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
const [isSubmittingReject, setIsSubmittingReject] = useState(false);
|
||||
|
||||
// Preiskorrektur States
|
||||
const [isEditingSnapshot, setIsEditingSnapshot] = useState(false);
|
||||
const [editedSnapshot, setEditedSnapshot] = useState<any | null>(null);
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
|
||||
// Initialize editedSnapshot when selectedOrder changes
|
||||
useEffect(() => {
|
||||
if (selectedOrder) {
|
||||
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||
setIsEditingSnapshot(false);
|
||||
} else {
|
||||
setEditedSnapshot(null);
|
||||
setIsEditingSnapshot(false);
|
||||
}
|
||||
}, [selectedOrder]);
|
||||
|
||||
const updateItemBasePrice = (itemIdx: number, newPrice: number) => {
|
||||
if (!editedSnapshot) return;
|
||||
const updatedItems = [...editedSnapshot.items];
|
||||
const item = { ...updatedItems[itemIdx] };
|
||||
item.base_price = newPrice;
|
||||
|
||||
const moduleTotal = (item.selected_modules || []).reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||
item.item_total = newPrice + moduleTotal;
|
||||
|
||||
updatedItems[itemIdx] = item;
|
||||
recalculateSnapshot(updatedItems);
|
||||
};
|
||||
|
||||
const updateModulePriceOrQty = (itemIdx: number, modIdx: number, field: 'price' | 'quantity', value: number) => {
|
||||
if (!editedSnapshot) return;
|
||||
const updatedItems = [...editedSnapshot.items];
|
||||
const item = { ...updatedItems[itemIdx] };
|
||||
const updatedModules = [...(item.selected_modules || [])];
|
||||
const mod = { ...updatedModules[modIdx] };
|
||||
|
||||
if (field === 'price') {
|
||||
mod.price = value;
|
||||
} else {
|
||||
mod.quantity = value;
|
||||
}
|
||||
mod.total_price = mod.price * (mod.quantity || 1);
|
||||
updatedModules[modIdx] = mod;
|
||||
|
||||
item.selected_modules = updatedModules;
|
||||
const moduleTotal = updatedModules.reduce((acc: number, m: any) => acc + (m.total_price ?? (m.price * (m.quantity || 1))), 0);
|
||||
item.item_total = (item.base_price || 0) + moduleTotal;
|
||||
|
||||
updatedItems[itemIdx] = item;
|
||||
recalculateSnapshot(updatedItems);
|
||||
};
|
||||
|
||||
const recalculateSnapshot = (updatedItems: any[]) => {
|
||||
if (!editedSnapshot) return;
|
||||
const taxRate = editedSnapshot.tax_rate ?? 19;
|
||||
const subtotal = updatedItems.reduce((acc: number, item: any) => acc + (item.item_total || 0), 0);
|
||||
const taxAmount = Math.round(subtotal * (taxRate / 100) * 100) / 100;
|
||||
const total = Math.round((subtotal + taxAmount) * 100) / 100;
|
||||
|
||||
setEditedSnapshot({
|
||||
...editedSnapshot,
|
||||
items: updatedItems,
|
||||
subtotal,
|
||||
tax_amount: taxAmount,
|
||||
total
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSnapshot = async () => {
|
||||
if (!editedSnapshot || !selectedOrder) return;
|
||||
setIsSavingSnapshot(true);
|
||||
try {
|
||||
const updated = await updateOrderPayload(selectedOrder.id, {
|
||||
orderSnapshot: editedSnapshot
|
||||
});
|
||||
setOrders(prev => prev.map(o => o.id === selectedOrder.id ? { ...o, order_data: editedSnapshot, total_price: editedSnapshot.total } : o));
|
||||
setSelectedOrder({ ...selectedOrder, order_data: editedSnapshot, total_price: editedSnapshot.total });
|
||||
setIsEditingSnapshot(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Speichern der Änderungen: " + error.message);
|
||||
} finally {
|
||||
setIsSavingSnapshot(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Badge-Counter berechnen
|
||||
const countAll = orders.length;
|
||||
const countOffen = orders.filter(o => ["pending", "pending_approval", "in_review"].includes(o.status)).length;
|
||||
const countApproved = orders.filter(o => o.status === "approved").length;
|
||||
const countActive = orders.filter(o => ["active", "completed"].includes(o.status)).length;
|
||||
const countRejected = orders.filter(o => ["rejected", "cancelled"].includes(o.status)).length;
|
||||
|
||||
const displaySnapshot = editedSnapshot || selectedOrder?.order_data || {};
|
||||
const displayItems = displaySnapshot.items || [];
|
||||
|
||||
useEffect(() => {
|
||||
setOrders(initialOrders);
|
||||
}, [initialOrders]);
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled') => {
|
||||
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'pending_approval' | 'in_review' | 'approved' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
|
||||
if (newStatus === 'rejected') {
|
||||
const target = orders.find(o => o.id === orderId);
|
||||
if (target) {
|
||||
setRejectingOrder(target);
|
||||
setRejectionReason("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUpdatingId(orderId);
|
||||
try {
|
||||
await updateOrderStatus(orderId, newStatus);
|
||||
setOrders(prev => prev.map(o => o.id === orderId ? { ...o, status: newStatus } : o));
|
||||
if (selectedOrder?.id === orderId) {
|
||||
setSelectedOrder((prev: any) => prev ? { ...prev, status: newStatus } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Ändern des Status.");
|
||||
@@ -55,75 +208,142 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
const orderNum = (order.order_number || "").toLowerCase();
|
||||
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||
|
||||
const matchesSearch =
|
||||
orderNum.includes(search.toLowerCase()) ||
|
||||
company.includes(search.toLowerCase()) ||
|
||||
contact.includes(search.toLowerCase());
|
||||
const handleConfirmReject = async () => {
|
||||
if (!rejectingOrder) return;
|
||||
setIsSubmittingReject(true);
|
||||
try {
|
||||
await rejectOrder(rejectingOrder.id, rejectionReason || "Vom Administrator abgelehnt.");
|
||||
setOrders(prev => prev.map(o => o.id === rejectingOrder.id ? { ...o, status: 'rejected' } : o));
|
||||
if (selectedOrder?.id === rejectingOrder.id) {
|
||||
setSelectedOrder((prev: any) => prev ? { ...prev, status: 'rejected' } : null);
|
||||
}
|
||||
setRejectingOrder(null);
|
||||
setRejectionReason("");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Fehler beim Ablehnen der Anfrage.");
|
||||
} finally {
|
||||
setIsSubmittingReject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
const filteredAndSortedOrders = orders
|
||||
.filter((order) => {
|
||||
const orderNum = (order.order_number || "").toLowerCase();
|
||||
const company = (order.customer_data?.company_name || "").toLowerCase();
|
||||
const contact = `${order.customer_data?.first_name || ""} ${order.customer_data?.last_name || ""}`.toLowerCase();
|
||||
|
||||
const matchesSearch =
|
||||
orderNum.includes(search.toLowerCase()) ||
|
||||
company.includes(search.toLowerCase()) ||
|
||||
contact.includes(search.toLowerCase());
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
let matchesStatus = false;
|
||||
if (statusFilter === "all") matchesStatus = true;
|
||||
else if (statusFilter === "offen") matchesStatus = ["pending", "pending_approval", "in_review"].includes(order.status);
|
||||
else if (statusFilter === "approved") matchesStatus = order.status === "approved";
|
||||
else if (statusFilter === "active") matchesStatus = ["active", "completed"].includes(order.status);
|
||||
else if (statusFilter === "rejected") matchesStatus = ["rejected", "cancelled"].includes(order.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'date_desc') return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
if (sortBy === 'date_asc') return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
if (sortBy === 'price_desc') return (b.total_price || 0) - (a.total_price || 0);
|
||||
if (sortBy === 'price_asc') return (a.total_price || 0) - (b.total_price || 0);
|
||||
if (sortBy === 'order_num') return (a.order_number || a.id).localeCompare(b.order_number || b.id);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filterleiste */}
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||
<div className="relative w-full md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Bestellung, Firma oder Name suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
{/* Filter- & Sortierleiste */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Anfragenr., Firma oder Name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 bg-slate-950/40 border-white/10 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="border-white/10 bg-slate-950/40 text-slate-200 gap-2 shrink-0">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 text-primary" />
|
||||
<span>
|
||||
{sortBy === 'date_desc' && 'Datum (Neueste zuerst)'}
|
||||
{sortBy === 'date_asc' && 'Datum (Älteste zuerst)'}
|
||||
{sortBy === 'price_desc' && 'Betrag (Höchster zuerst)'}
|
||||
{sortBy === 'price_asc' && 'Betrag (Niedrigster zuerst)'}
|
||||
{sortBy === 'order_num' && 'Anfragenr.'}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white">
|
||||
<DropdownMenuItem onClick={() => setSortBy('date_desc')} className="text-xs cursor-pointer">
|
||||
Datum (Neueste zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('date_asc')} className="text-xs cursor-pointer">
|
||||
Datum (Älteste zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('price_desc')} className="text-xs cursor-pointer">
|
||||
Betrag (Höchster zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('price_asc')} className="text-xs cursor-pointer">
|
||||
Betrag (Niedrigster zuerst)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setSortBy('order_num')} className="text-xs cursor-pointer">
|
||||
Anfragenr.
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant={statusFilter === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className="text-xs"
|
||||
className="text-xs gap-1.5"
|
||||
>
|
||||
Alle
|
||||
Alle <Badge variant="secondary" className="bg-white/10 text-white border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countAll}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "pending" ? "default" : "outline"}
|
||||
variant={statusFilter === "offen" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("pending")}
|
||||
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400"
|
||||
onClick={() => setStatusFilter("offen")}
|
||||
className="text-xs border-amber-500/20 hover:bg-amber-500/10 text-amber-400 gap-1.5"
|
||||
>
|
||||
Eingegangen
|
||||
Offen / in_review <Badge variant="secondary" className="bg-amber-500/20 text-amber-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countOffen}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "approved" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("approved")}
|
||||
className="text-xs border-emerald-500/20 hover:bg-emerald-500/10 text-emerald-400 gap-1.5"
|
||||
>
|
||||
Genehmigt <Badge variant="secondary" className="bg-emerald-500/20 text-emerald-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countApproved}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "active" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400"
|
||||
className="text-xs border-blue-500/20 hover:bg-blue-500/10 text-blue-400 gap-1.5"
|
||||
>
|
||||
In Bearbeitung
|
||||
Aktiv <Badge variant="secondary" className="bg-blue-500/20 text-blue-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countActive}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "completed" ? "default" : "outline"}
|
||||
variant={statusFilter === "rejected" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("completed")}
|
||||
className="text-xs border-green-500/20 hover:bg-green-500/10 text-green-400"
|
||||
onClick={() => setStatusFilter("rejected")}
|
||||
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400 gap-1.5"
|
||||
>
|
||||
Abgeschlossen
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === "cancelled" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter("cancelled")}
|
||||
className="text-xs border-red-500/20 hover:bg-red-500/10 text-red-400"
|
||||
>
|
||||
Storniert
|
||||
Abgelehnt <Badge variant="secondary" className="bg-red-500/20 text-red-400 border-0 font-normal px-1 py-0 h-4 min-w-[16px] flex items-center justify-center rounded-full text-[10px]">{countRejected}</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,31 +353,31 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-white/10 bg-white/5 hover:bg-transparent">
|
||||
<TableHead className="w-[140px] text-slate-200">Bestellnr.</TableHead>
|
||||
<TableHead className="text-slate-200">Datum</TableHead>
|
||||
<TableHead className="text-slate-200">Kunde</TableHead>
|
||||
<TableHead className="text-slate-200">Auswahl</TableHead>
|
||||
<TableHead className="text-slate-200">Betrag</TableHead>
|
||||
<TableHead className="text-slate-200">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-200">Aktionen</TableHead>
|
||||
<TableHead className="w-[140px] text-slate-200 font-bold">Anfragenr.</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Datum</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Kunde / Firma</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Auswahl / Produkte</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Gesamtbetrag</TableHead>
|
||||
<TableHead className="text-slate-200 font-bold">Status</TableHead>
|
||||
<TableHead className="text-right text-slate-200 font-bold">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.length === 0 ? (
|
||||
{filteredAndSortedOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-16 text-slate-500 italic">
|
||||
Keine Bestellungen gefunden.
|
||||
Keine Anfragen gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
filteredAndSortedOrders.map((order) => {
|
||||
const items = order.order_data?.items || [];
|
||||
return (
|
||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors">
|
||||
<TableCell className="font-mono text-xs text-primary font-semibold">
|
||||
<TableRow key={order.id} className="border-white/5 hover:bg-white/5 transition-colors group">
|
||||
<TableCell className="font-mono text-xs text-primary font-bold">
|
||||
#{order.order_number || order.id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 text-sm">
|
||||
<TableCell className="text-slate-300 text-sm whitespace-nowrap">
|
||||
{new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
})}
|
||||
@@ -166,33 +386,43 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
<div className="font-semibold text-white text-sm">
|
||||
{order.customer_data?.company_name || 'Privatkunde'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
<div className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
|
||||
<User className="w-3 h-3 text-slate-500" />
|
||||
{order.customer_data?.first_name} {order.customer_data?.last_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item: any) => (
|
||||
<span
|
||||
key={item.product_id}
|
||||
className="text-[10px] bg-white/5 border border-white/10 rounded-full px-2 py-0.5 text-slate-300 inline-block"
|
||||
<TableCell className="max-w-[240px]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.slice(0, 2).map((item: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-[11px] text-slate-300 leading-tight"
|
||||
>
|
||||
{item.product_name}
|
||||
<span className="font-semibold text-white block truncate">
|
||||
{item.device_name || 'Kasse'}: {item.product_name}
|
||||
</span>
|
||||
{item.selected_modules?.length > 0 && (
|
||||
<span className="text-slate-500 ml-1">+{item.selected_modules.length}</span>
|
||||
<span className="text-slate-500 block text-[10px] truncate">
|
||||
+ {item.selected_modules.map((m: any) => m.module_name || m.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length > 2 && (
|
||||
<span className="text-[10px] text-primary font-semibold">
|
||||
+ {items.length - 2} weitere Kassen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-bold text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||
<TableCell className="font-bold text-white text-sm whitespace-nowrap">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price || 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={updatingId === order.id}>
|
||||
<button className="focus:outline-none focus:ring-0 active:scale-95 disabled:pointer-events-none transition-transform duration-100">
|
||||
<Badge className={`text-xs px-2 py-0.5 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||
<Badge className={`text-xs px-2.5 py-1 rounded cursor-pointer transition-all duration-200 hover:opacity-85 flex items-center gap-1.5 ${statusClass[order.status] ?? 'bg-slate-500/20 text-slate-300'}`}>
|
||||
{updatingId === order.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : null}
|
||||
@@ -201,7 +431,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</Badge>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[140px]">
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 border-white/10 text-white min-w-[170px]">
|
||||
{Object.entries(statusLabel).map(([key, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
@@ -217,27 +447,24 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs">
|
||||
<a href={`/order?id=${order.id}`}>
|
||||
Bearbeiten
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedOrder(order)}
|
||||
className="h-8 border-white/10 hover:bg-white/10 text-slate-200 text-xs gap-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-primary" /> Details
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||
<a href={`/order?mode=admin_edit&orderId=${order.id}`}>
|
||||
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||
<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>
|
||||
{order.pdf_url ? (
|
||||
<>
|
||||
<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={`/api/admin/orders/${order.id}/download`}>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic self-center px-2">Keine Bestätigung</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -247,6 +474,345 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Detail-Modal für vollständige Anfragenübersicht & Freigabe-Workflow */}
|
||||
{selectedOrder && (
|
||||
<Dialog open={!!selectedOrder} onOpenChange={(open) => !open && setSelectedOrder(null)}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto bg-slate-950 border-white/10 text-white p-6 space-y-6">
|
||||
<DialogHeader className="border-b border-white/10 pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DialogTitle className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
Anfragenr. #{selectedOrder.order_number || selectedOrder.id.slice(0, 8)}
|
||||
</DialogTitle>
|
||||
<Badge className={`text-xs px-2.5 py-0.5 rounded ${statusClass[selectedOrder.status]}`}>
|
||||
{statusLabel[selectedOrder.status] ?? selectedOrder.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="text-xs text-slate-400 mt-1 flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||
Erstellt am {new Date(selectedOrder.created_at).toLocaleString('de-DE')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-400 uppercase tracking-wider font-semibold">
|
||||
{isEditingSnapshot ? "Vorschau Summe" : "Gesamtsumme"}
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-primary">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
isEditingSnapshot ? (editedSnapshot?.total || 0) : (selectedOrder.total_price || 0)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Kunden & Stammdaten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
|
||||
<Building2 className="w-4 h-4 text-primary" />
|
||||
Kunden- & Firmendaten
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{selectedOrder.customer_data?.company_name || 'Keine Firma angegeben'}
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-slate-500" />
|
||||
{selectedOrder.customer_data?.first_name} {selectedOrder.customer_data?.last_name}
|
||||
</div>
|
||||
{selectedOrder.customer_data?.email && (
|
||||
<div className="text-xs text-slate-300 flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{selectedOrder.customer_data.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/80 border border-white/5 space-y-2">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5 border-b border-white/5 pb-2">
|
||||
<MapPin className="w-4 h-4 text-primary" />
|
||||
Adresse & Standort
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 space-y-1">
|
||||
<div>{selectedOrder.customer_data?.street || 'Keine Straße'}</div>
|
||||
<div>
|
||||
{selectedOrder.customer_data?.zip} {selectedOrder.customer_data?.city}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geräte & Produktauswahl (Nach Kassen / Boxen gruppiert) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Package className="w-4 h-4 text-primary" />
|
||||
Konfigurierte Kassen ({displayItems.length})
|
||||
</div>
|
||||
{selectedOrder.status === 'in_review' && !isEditingSnapshot && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsEditingSnapshot(true)}
|
||||
className="h-7 border-primary/30 text-primary hover:bg-primary/10 text-xs font-bold gap-1 rounded-md"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" /> Preise anpassen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingSnapshot && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between bg-amber-500/10 border border-amber-500/20 p-3 rounded-xl gap-4 animate-pulse">
|
||||
<span className="text-xs text-amber-400 font-semibold">Sie befinden sich im Preiskorrektur-Modus.</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditedSnapshot(JSON.parse(JSON.stringify(selectedOrder.order_data || {})));
|
||||
setIsEditingSnapshot(false);
|
||||
}}
|
||||
className="h-7 text-xs text-slate-300 hover:text-white"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveSnapshot}
|
||||
disabled={isSavingSnapshot}
|
||||
className="h-7 text-xs bg-amber-600 hover:bg-amber-500 text-white font-bold"
|
||||
>
|
||||
{isSavingSnapshot ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : null}
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{displayItems.map((item: any, idx: number) => {
|
||||
const devName = item.device_name || `Kasse #${idx + 1}`
|
||||
const licNum = item.license_number
|
||||
const modules = item.selected_modules || []
|
||||
|
||||
return (
|
||||
<div key={idx} className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3 relative overflow-hidden">
|
||||
{/* Box Header für die Kasse */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-2.5 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 rounded text-xs font-extrabold bg-blue-500/20 text-blue-400 border border-blue-500/30 font-mono">
|
||||
{devName}
|
||||
</span>
|
||||
{licNum && (
|
||||
<span className="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-sans">Lizenz:</span>
|
||||
{licNum}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded border ${item.billing_interval === 'one_time' ? 'border-amber-500/30 text-amber-400 bg-amber-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'} font-semibold uppercase`}>
|
||||
{item.billing_interval === 'one_time' ? 'Kauf' : 'Abo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Inhalt der Kasse: Hauptprodukt + Preis */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm text-white">{item.product_name}</span>
|
||||
{isEditingSnapshot ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`price-${idx}`} className="text-[10px] text-slate-400 font-sans">Preis (€):</Label>
|
||||
<Input
|
||||
id={`price-${idx}`}
|
||||
type="number"
|
||||
value={item.base_price || 0}
|
||||
onChange={(e) => updateItemBasePrice(idx, parseFloat(e.target.value) || 0)}
|
||||
className="w-20 h-7 bg-slate-950 border-white/10 text-white text-xs px-2 font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
(item.price || item.base_price) && (
|
||||
<span className="text-xs font-bold text-slate-200 font-mono">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.price || item.base_price)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module dieser Kasse */}
|
||||
{modules.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-white/5">
|
||||
<div className="text-[11px] font-semibold text-slate-400 flex items-center gap-1">
|
||||
<Layers className="w-3 h-3 text-slate-500" />
|
||||
Enthaltene Module ({modules.length}):
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{modules.map((mod: any, mIdx: number) => (
|
||||
<div key={mIdx} className="p-2 rounded-lg bg-slate-950/60 border border-white/5 text-xs flex items-center justify-between gap-3">
|
||||
<span className="text-slate-300 font-medium truncate">
|
||||
+ {mod.module_name || mod.name} {!isEditingSnapshot && mod.quantity > 1 ? `(${mod.quantity}x)` : ''}
|
||||
</span>
|
||||
{isEditingSnapshot ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
title="Einzelpreis"
|
||||
value={mod.price || 0}
|
||||
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'price', parseFloat(e.target.value) || 0)}
|
||||
className="w-14 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500 font-sans">x</span>
|
||||
<Input
|
||||
type="number"
|
||||
title="Menge"
|
||||
value={mod.quantity || 1}
|
||||
onChange={(e) => updateModulePriceOrQty(idx, mIdx, 'quantity', parseInt(e.target.value) || 1)}
|
||||
className="w-10 h-6 bg-slate-900 border-white/10 text-white text-[10px] px-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
mod.price && (
|
||||
<span className="text-slate-400 font-mono text-[11px] shrink-0">
|
||||
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * (mod.quantity || 1))}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workflow Freigabe & Aktionen */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-white/10 space-y-3">
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-amber-400" />
|
||||
Freigabe-Workflow & Verwalter-Aktionen
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'in_review')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white text-xs gap-1.5"
|
||||
>
|
||||
In Prüfung versetzen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'approved')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white text-xs gap-1.5 font-bold"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> Anfrage Freigeben
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'active')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white text-xs gap-1.5 font-bold"
|
||||
>
|
||||
Sofort Aktivieren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleStatusChange(selectedOrder.id, 'rejected')}
|
||||
disabled={updatingId === selectedOrder.id}
|
||||
className="text-xs gap-1.5"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" /> Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-white/10 pt-4 flex flex-col sm:flex-row justify-between items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedOrder(null)}
|
||||
className="text-slate-400 hover:text-white text-xs"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-amber-500/20 hover:bg-amber-500/10 text-amber-400 text-xs gap-1">
|
||||
<a href={`/order?mode=admin_edit&orderId=${selectedOrder.id}`}>
|
||||
<Edit3 className="w-3 h-3" /> Im Wizard öffnen
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="h-8 border-white/10 hover:bg-primary/20 text-xs">
|
||||
<a href={`/api/admin/orders/${selectedOrder.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1" /> PDF Vorschau
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild className="h-8 text-xs">
|
||||
<a href={`/api/admin/orders/${selectedOrder.id}/download`}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> PDF Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Dialog für Ablehnungsbegründung */}
|
||||
{rejectingOrder && (
|
||||
<Dialog open={!!rejectingOrder} onOpenChange={(open) => !open && setRejectingOrder(null)}>
|
||||
<DialogContent className="max-w-md bg-slate-950 border-white/10 text-white p-6 space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold text-red-400 flex items-center gap-2">
|
||||
<XCircle className="w-5 h-5" />
|
||||
Anfrage Ablehnen
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-slate-400">
|
||||
Bitte geben Sie einen Grund für die Ablehnung der Anfrage #{rejectingOrder.order_number || rejectingOrder.id.slice(0, 8)} an.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-300">Begründung für den Partner</Label>
|
||||
<Input
|
||||
placeholder="z.B. Ungültiges Kundenprofil / Modul-Konflikt..."
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="bg-slate-900 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRejectingOrder(null)}
|
||||
disabled={isSubmittingReject}
|
||||
className="text-xs text-slate-400"
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleConfirmReject}
|
||||
disabled={isSubmittingReject}
|
||||
className="text-xs gap-1 font-bold"
|
||||
>
|
||||
{isSubmittingReject ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
|
||||
Anfrage definitiv ablehnen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { Label } from "@/components/ui/label"
|
||||
|
||||
export function ProductList({ initialProducts, categories }: { initialProducts: Product[], categories: Category[] }) {
|
||||
const [products, setProducts] = useState(initialProducts)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(false)
|
||||
const [isGroupedByCategory, setIsGroupedByCategory] = useState(true)
|
||||
const [showKauf, setShowKauf] = useState(true)
|
||||
const [showAbo, setShowAbo] = useState(true)
|
||||
const [copySourceProduct, setCopySourceProduct] = useState<Product | null>(null)
|
||||
@@ -202,7 +202,7 @@ export function ProductList({ initialProducts, categories }: { initialProducts:
|
||||
size="sm"
|
||||
onClick={toggleGroupByCategory}
|
||||
className={`border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 text-slate-700 dark:text-white ${
|
||||
isGroupedByCategory ? 'bg-primary/10 border-primary text-primary hover:bg-primary/20 dark:bg-primary/20 dark:border-primary dark:text-primary-foreground' : ''
|
||||
isGroupedByCategory ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 hover:text-white dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700' : ''
|
||||
}`}
|
||||
>
|
||||
<ArrowUpDown className="w-4 h-4 mr-2" />
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user