Compare commits

121 Commits

Author SHA1 Message Date
DanielS
13f647409f fix(sync): query customers by vowels to avoid crash
All checks were successful
Staging Build / build (push) Successful in 2m56s
2026-07-15 23:50:13 +02:00
DanielS
5d3eed874c feat(sync): add customer synchronization from lic server
All checks were successful
Staging Build / build (push) Successful in 3m0s
2026-07-15 22:48:10 +02:00
DanielS
3a090b7664 docs: document licserver management api
All checks were successful
Staging Build / build (push) Successful in 2m59s
2026-07-15 16:18:30 +02:00
DanielS
e2d92a031f feat(licserver): add partner customers proxy API 2026-07-15 16:14:31 +02:00
DanielS
6b9023c871 feat(wizard): connect license lookup panel to real lic server api endpoint
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 01:01:01 +02:00
DanielS
f783a98392 docs: add lic server api spec and index in second brain 2026-07-15 00:58:13 +02:00
DanielS
d5593900fb feat(cron): add automated sync endpoint and vercel cron configuration for partners
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-15 00:30:13 +02:00
DanielS
92eb7454dc docs: update database schema documentation in second brain and grand functions 2026-07-15 00:27:03 +02:00
DanielS
cb4787e595 style(admin): replace browser alert and confirm with dialog modals
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-07-15 00:21:38 +02:00
DanielS
3fb80e6404 feat(admin): add manual partner import from lic server
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-15 00:07:31 +02:00
DanielS
a318a5e225 fix(admin): wrap access check in try-catch to prevent loader hanging
All checks were successful
Staging Build / build (push) Successful in 2m57s
2026-07-14 23:52:04 +02:00
DanielS
1b49348577 style: simplify home page layout and fix navbar link 2026-07-14 23:44:21 +02:00
DanielS
fb61f5f396 style: remove text gradients and centered badges 2026-07-14 23:42:28 +02:00
DanielS
7119383139 fix(security): harden licserver config access controls
All checks were successful
Staging Build / build (push) Successful in 2m50s
- Add auth guard to getLicServerConfig() - unauthenticated callers
  now receive null config instead of DB values
- Remove non-existent 'superadmin' role from saveLicServerConfig()
  check (only 'admin' exists per project schema)
- New migration: restrict settings table WRITE to admins only
  (previously any authenticated user could overwrite licserver_api_key)
2026-07-14 17:35:12 +02:00
DanielS
fa93e060c2 feat(admin): LicServer config manageable via admin settings UI
Some checks failed
Staging Build / build (push) Has been cancelled
- Migration: add licserver_base_url/api_key cols to settings table
- New: lib/actions/licserver-config.ts (read/write/test server actions)
- Updated: proxy routes read config from DB with env-var fallback
- Updated: admin/einstellungen adds LicServer card with URL + API key
  input, show/hide toggle, Save and Connection Test buttons
2026-07-14 17:30:37 +02:00
DanielS
be4e9204a3 feat(licserver): add partner API proxy routes and types
All checks were successful
Staging Build / build (push) Successful in 2m54s
- New: lib/types/licserver.ts (PartnerDto, paged result, problem)
- New: app/api/licserver/partners/route.ts (GET search/list)
- New: app/api/licserver/partners/[id]/route.ts (GET by UUID)
- Updated: .env.example with LICSERVER_BASE_URL + LICSERVER_API_KEY

Auth-guarded via Supabase session. API key is server-side only.
Upstream: http://192.168.178.174:9980 (internal container network)
2026-07-14 17:24:00 +02:00
DanielS
2e30974aed fix(wizard): give summary sidebar equal 2/6 col width
All checks were successful
Staging Build / build (push) Successful in 2m34s
2026-07-14 16:55:45 +02:00
DanielS
c30505de05 feat(wizard): expand step 3 container to max-w-screen-2xl
Some checks failed
Staging Build / build (push) Failing after 16m18s
2026-07-14 15:24:29 +02:00
DanielS
5a2afd8600 feat(wizard): widen license lookup panel to 2 cols
All checks were successful
Staging Build / build (push) Successful in 2m55s
2026-07-14 14:35:00 +02:00
DanielS
72b906779c feat(wizard): add license lookup panel (UI mockup)
All checks were successful
Staging Build / build (push) Successful in 2m55s
Add collapsible LicenseLookupPanel component to step 3
of the order wizard (left column). Panel queries mock
LicServer data by license key and displays product,
customer, dates, status badge and licensed modules.
Real API endpoint can replace mock in lookupLicense().

- New: components/wizard/license-lookup-panel.tsx
- Modified: components/order-wizard.tsx (4-col grid)
2026-07-14 12:57:46 +02:00
DanielS
0b4755d438 feat: split step-3 into independent left/right grid columns with sticky local stepper
All checks were successful
Staging Build / build (push) Successful in 2m51s
2026-07-10 16:10:25 +02:00
DanielS
d37a6b4348 feat: sticky header with stepper for step 3, sidebar aligns under header
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-10 15:58:14 +02:00
DanielS
21550f1389 fix: sidebar top-[24vh] prevents stepper overlap, reduce max-h to 70vh
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-10 15:43:24 +02:00
DanielS
43e6a92663 feat: implement category flags, drop global tables, fix sidebar sticky layout
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-10 15:27:55 +02:00
DanielS
305671acc6 fix: stepper circles always blue, replace bg-primary with bg-blue-600 2026-07-10 15:23:43 +02:00
DanielS
17d76f55e6 refactor: subdivide monolithic order wizard into clean modular step components
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-07-10 15:11:03 +02:00
DanielS
4e82146ab7 feat(wizard): replace browser alerts with modern custom UI toasts 2026-07-10 15:03:55 +02:00
DanielS
83a019785b style(wizard): change add-to-basket button label dynamically to Kasse speichern and Kasse anlegen 2026-07-10 15:01:46 +02:00
DanielS
f091b97ca0 fix(wizard): prevent duplicate device names in the shopping cart 2026-07-10 14:59:10 +02:00
DanielS
6d6bb4dfe6 style(css): remove text-gradient in favor of solid blue color
All checks were successful
Staging Build / build (push) Successful in 2m53s
2026-07-10 14:54:24 +02:00
DanielS
e59039dd4e style(wizard): show checkmark icon for past completed steps in stepper 2026-07-10 14:51:14 +02:00
DanielS
f80866218b style(wizard): highlight edited item, improve sidebar styling, and add cart count badge to stepper 2026-07-10 14:49:47 +02:00
DanielS
545b5e93bf style(admin): change wizard next button text to black
All checks were successful
Staging Build / build (push) Successful in 3m2s
2026-07-10 14:43:36 +02:00
DanielS
e9117d34a0 style(admin): set category grouping to true by default and color button blue when active 2026-07-10 14:43:11 +02:00
DanielS
d72830412e refactor(wizard): improve basket CRUD and submit lifecycle with editingIdx state 2026-07-10 14:40:49 +02:00
DanielS
0a9997e98a style(wizard): show numbers instead of check icons in progress stepper 2026-07-10 11:18:40 +02:00
DanielS
0b268881a1 style(wizard): dock stepper in step 3 and make summary card sticky top-[20vh]
All checks were successful
Staging Build / build (push) Successful in 2m50s
2026-07-10 10:52:07 +02:00
DanielS
993c817dff fix(checkout): correct import path for buildEmailItemsSection
All checks were successful
Staging Build / build (push) Successful in 2m43s
2026-07-10 10:02:52 +02:00
DanielS
4da386f693 fix(build): move buildEmailItemsSection to email-templates.ts to fix Turbopack build failure
Some checks failed
Staging Build / build (push) Failing after 25s
2026-07-10 10:00:49 +02:00
DanielS
c252fa11d0 fix(checkout): scope fileName correctly in checkout route
Some checks failed
Staging Build / build (push) Failing after 25s
2026-07-10 09:55:49 +02:00
DanielS
1a7a765d72 fix(checkout): use full email template with PDF attachment for checkout API route
Some checks failed
Staging Build / build (push) Failing after 26s
2026-07-10 09:54:02 +02:00
DanielS
ec1ad4d444 fix(checkout): use full email template with PDF attachment for checkout confirmations
Some checks failed
Staging Build / build (push) Failing after 24s
2026-07-10 09:45:21 +02:00
DanielS
0679fdfdd7 fix(admin): close DialogFooter tag in product dialog
All checks were successful
Staging Build / build (push) Successful in 2m45s
2026-07-10 09:37:34 +02:00
DanielS
0ecb16daff feat(admin): support full view in product edit and wizard steps in product create 2026-07-10 09:36:30 +02:00
DanielS
c8183abff7 fix(admin): close FormField and FormItem correctly in create product dialog
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-07-10 09:29:57 +02:00
DanielS
0435984c82 feat(admin): make product dialog wider and improve module additions layout
Some checks failed
Staging Build / build (push) Failing after 26s
2026-07-10 09:28:09 +02:00
DanielS
27fe6c6eef feat(admin): move allow_update_discount option to product level
All checks were successful
Staging Build / build (push) Successful in 2m50s
2026-07-10 09:15:34 +02:00
DanielS
bc6487bba4 feat(email): include order item configurations in confirmation email
All checks were successful
Staging Build / build (push) Successful in 2m54s
2026-07-10 08:57:52 +02:00
DanielS
d8b7076e7e feat(admin): add allow_update_discount option to category dialog 2026-07-10 08:55:53 +02:00
DanielS
9d888e6380 docs(grand-functions): align references with codebase 2026-07-10 08:54:00 +02:00
DanielS
2f92213594 feat: reconstruct multiple devices from initialOrder items into basketItems for editing
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-07-10 01:10:39 +02:00
DanielS
4fa2daf212 fix: auto-save current uncommitted wizard state to basket before editing another item
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-10 01:01:28 +02:00
DanielS
dc5465e289 chore: align device terminology to cash register across wizard and success pages
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-10 00:55:12 +02:00
DanielS
99cbb11c5a feat: add edit and delete actions to basket items for device configuration switching
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-10 00:52:22 +02:00
DanielS
a95afbf443 fix: attach device_name to order items in API checkout route
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-07-10 00:45:20 +02:00
DanielS
cd3fba2113 fix: add device_name to OrderItem type definition
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-10 00:23:37 +02:00
DanielS
6821b4e8d9 fix: group success page order items by device name
Some checks failed
Staging Build / build (push) Failing after 28s
2026-07-10 00:20:32 +02:00
DanielS
a3c5a66d3f feat: check category eligibility for update discount in frontend and backend
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-10 00:18:23 +02:00
DanielS
2038c4df3e db: add allow_update_discount column to categories table 2026-07-10 00:18:18 +02:00
DanielS
90a5e17f2b fix: design-beautify of device sections in quotation confirmation pdf
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-10 00:10:45 +02:00
DanielS
3b16722fca fix: correctly split and display one-time and monthly recurring items in purchase order pdf
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 23:57:17 +02:00
DanielS
1e7112fa3c feat: group items by device name in pdf quotation confirmation
All checks were successful
Staging Build / build (push) Successful in 2m47s
2026-07-09 23:53:58 +02:00
DanielS
db95e435c2 fix: auto add configured device to basket on step 3 transition
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 23:43:16 +02:00
DanielS
c3f97f8dca fix: add missing type and payment_method columns to orders table 2026-07-09 23:43:12 +02:00
DanielS
98b1301e96 fix: resolve typescript implicit any type errors in order-wizard.tsx
All checks were successful
Staging Build / build (push) Successful in 2m50s
2026-07-09 23:35:59 +02:00
DanielS
387ba19802 fix: loop and render all basket items in wizard summary screen
Some checks failed
Staging Build / build (push) Failing after 29s
2026-07-09 23:33:16 +02:00
DanielS
2c572c5bd4 fix: add missing payment_method column to orders table 2026-07-09 23:33:13 +02:00
DanielS
ae4a8429c7 fix: rewrite auth middleware to prevent global login lock for partners and admins
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 23:16:22 +02:00
DanielS
58f946490c docs: document is_admin recursion bypass in grand functions
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-09 23:15:20 +02:00
DanielS
5cd78854e3 db: fix infinite RLS recursion on users table causing admin lockout 2026-07-09 23:15:16 +02:00
DanielS
fb9b522e74 chore: clean up order-wizard layout text and spacing
All checks were successful
Staging Build / build (push) Successful in 2m32s
2026-07-09 23:08:10 +02:00
DanielS
c7a4b2a9c9 docs: document asynchronous post-processing and postgres user role triggers in grand functions
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-09 23:06:08 +02:00
DanielS
5b03de6b7e db: add strict rls policy to protect user roles from self-escalation 2026-07-09 23:06:03 +02:00
DanielS
100ad254cb perf: decouple pdf and mail generation from synchronous checkout flow 2026-07-09 23:06:00 +02:00
DanielS
d122c957eb docs: align grand functions perfectly with B2B quotation terminology and type mappings
All checks were successful
Staging Build / build (push) Successful in 2m42s
2026-07-09 23:02:36 +02:00
DanielS
a93fb975c0 docs: finalize request terminology updates and clean up license transforms
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-09 22:58:35 +02:00
DanielS
21c4aa859e fix: rename invoice terminology to request and quotation confirmation across pdf and mail services 2026-07-09 22:58:29 +02:00
DanielS
20f7d5a05a fix: resolve syntax error in invoice-pdf.tsx 2026-07-09 22:57:31 +02:00
DanielS
2b92626bf9 feat: add multi-device basket loop and add-another-device button to wizard
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 22:48:31 +02:00
DanielS
8bd47905b0 fix: resolve bracket syntax error in order-wizard.tsx
All checks were successful
Staging Build / build (push) Successful in 2m52s
2026-07-09 22:24:53 +02:00
DanielS
309dc33acd docs: document sub-product disable rules and no bottom-up reset in grand functions
Some checks failed
Staging Build / build (push) Failing after 26s
2026-07-09 22:22:07 +02:00
DanielS
d1cc6b5d5f fix: disable incompatible sub-products in wizard and prevent bottom-up reset 2026-07-09 22:22:04 +02:00
DanielS
13981970f5 docs: document frontend top-down cascade and visibility guarantee
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 22:04:14 +02:00
DanielS
8dd5fd3f44 fix: enforce top-down state cascade and prevent base product hiding 2026-07-09 22:04:10 +02:00
DanielS
ac4416f7ab docs: document test environment and scenarios in grand functions
All checks were successful
Staging Build / build (push) Successful in 2m50s
2026-07-09 21:56:18 +02:00
DanielS
992e726406 test: add unit tests for frontend top-down auto-reset and backend split logic 2026-07-09 21:56:13 +02:00
DanielS
988625ba42 chore: install vitest and configure test environment 2026-07-09 21:56:10 +02:00
DanielS
0621a0be16 docs: document checkout server action details in grand functions
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-09 21:53:45 +02:00
DanielS
926650d3b6 feat: implement checkout server action with idempotency guard and split 2026-07-09 21:53:42 +02:00
DanielS
cb12a6ced4 docs: document post-processing and layout differentiation in grand functions
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-09 21:51:26 +02:00
DanielS
66039eb4cf feat: implement pdf generation service based on jsonb snapshots 2026-07-09 21:51:23 +02:00
DanielS
5eef6c76ef docs: document checkout split API and payload in grand functions 2026-07-09 21:50:44 +02:00
DanielS
f71fc7f88d feat: create checkout API route with checkout split logic 2026-07-09 21:50:42 +02:00
DanielS
60e64c689d feat: implement wizard top-down state management and auto-reset 2026-07-09 21:49:53 +02:00
DanielS
bd7d5dd1b2 db: create core schema for products, categories, global rules and orders 2026-07-09 21:49:50 +02:00
DanielS
54b9dea70f docs: restructure agentsspec into grand-functions skill 2026-07-09 21:49:47 +02:00
DanielS
6b35917a06 feat(category): add resets_others option
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 17:27:16 +02:00
DanielS
567d1f226b feat(admin): add product dependencies and clean up labels
All checks were successful
Staging Build / build (push) Successful in 2m43s
- Add addProductDependency and removeProductDependency actions

- Create DependencyPopover and integrate in live editor UI

- Render green requirements and red exclusions badges

- Remove technical parentheses from category checkbox labels
2026-07-09 16:12:57 +02:00
DanielS
215da5f25b style(admin): align sidebar link style
All checks were successful
Staging Build / build (push) Successful in 2m46s
2026-07-09 15:57:37 +02:00
DanielS
d05274700a feat(admin): extend wysiwyg live editor
All checks were successful
Staging Build / build (push) Successful in 2m46s
- Add is_required and allow_multiselect category toggles

- Implement module-popover for product module assignment

- Add cascade-protected deletion for categories and products

- Add sidebar link with AdminNavLink active states
2026-07-09 15:52:47 +02:00
DanielS
16917bcbdc feat(admin): add wysiwyg editor for order wizard
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-09 15:39:14 +02:00
DanielS
915d3b1b2f Revert "feat: support product quantity and separate steps"
All checks were successful
Staging Build / build (push) Successful in 2m48s
This reverts commit 4ef21ea144.
2026-07-09 15:19:38 +02:00
DanielS
4ef21ea144 feat: support product quantity and separate steps
All checks were successful
Staging Build / build (push) Successful in 6m17s
2026-07-09 15:10:21 +02:00
DanielS
071e669b71 feat(categories): add branch visibility selection
All checks were successful
Staging Build / build (push) Successful in 2m43s
2026-07-09 14:26:56 +02:00
DanielS
07c7d8c656 feat(categories): allow disabling preselection
All checks were successful
Staging Build / build (push) Successful in 2m45s
2026-07-09 14:10:25 +02:00
DanielS
bffaca1d63 feat(orders): add rejected status to order schema and admin table views
All checks were successful
Staging Build / build (push) Successful in 2m49s
2026-07-08 10:13:21 +02:00
DanielS
cc7445e4c5 feat(multiselect): support multi-select categories and free items limits in category config and order wizard
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-07-07 16:56:24 +02:00
DanielS
00d1be3e4f fix(order-wizard): pass selectedCompanyId when creating end customer as admin
All checks were successful
Staging Build / build (push) Successful in 2m37s
2026-07-07 13:17:15 +02:00
DanielS
98c030cd82 fix(end-customers): allow admin to create end customers without company_id
All checks were successful
Staging Build / build (push) Successful in 2m38s
2026-07-07 12:32:11 +02:00
DanielS
8e91b3e810 fix(email): remove duplicate pdf download button 2026-07-07 10:30:34 +02:00
DanielS
dd5d1295a7 fix(invoice): update subtitle from Software Solutions to Die Kasse 2026-07-07 10:30:21 +02:00
DanielS
cce9812bbc fix(pdf): add gap and minWidth to prevent label-value collision in total rows
All checks were successful
Staging Build / build (push) Successful in 2m41s
2026-07-07 09:09:14 +02:00
DanielS
e00acac626 fix(pdf): widen total container and reduce font to prevent label-value collision
All checks were successful
Staging Build / build (push) Successful in 2m40s
2026-07-07 09:00:54 +02:00
DanielS
0ec1319753 style(mail): humanize email templates and remove corporate boilerplates
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-07-07 00:39:06 +02:00
DanielS
057012e09f chore(supabase): remove unused pos_modules and licenses tables 2026-07-07 00:23:54 +02:00
DanielS
2246bdf2be refactor(shop): extract wizard validation and email templates into helpers
All checks were successful
Staging Build / build (push) Successful in 2m48s
2026-07-06 23:15:11 +02:00
DanielS
3a73083494 fix(supabase): remove invalid public.partners reference in migration
All checks were successful
Staging Build / build (push) Successful in 2m39s
2026-07-06 22:57:18 +02:00
DanielS
73b6d19703 docs(skills): add commit-work skill with frontmatter
All checks were successful
Staging Build / build (push) Successful in 2m44s
2026-07-06 22:47:14 +02:00
DanielS
2d44dd4c58 security: fix 6 critical RLS vulns, SHA-256 hash, DSGVO [GELÖSCHT], completed status
Some checks failed
Staging Build / build (push) Has been cancelled
2026-07-06 22:39:05 +02:00
DanielS
3d938d29fc feat: add skills (pdf-invoice-generator, idempotency-guard, b2b-mail-dispatcher) 2026-07-06 22:33:59 +02:00
DanielS
c07871eac4 feat: add agent skills (rls, wizard, snapshot, gdpr, next15, uiux, humanizer) 2026-07-06 22:27:42 +02:00
103 changed files with 9282 additions and 2084 deletions

View 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.

View 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>"`.

View 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.

View 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)

View 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.

View 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.

View 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)

View 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.

View 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).

View 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

View 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.

View 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.

View 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`.

View 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.

View 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.

View 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).

View 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).

View 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
View 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
View 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
View 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
View 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)

View File

@@ -1,144 +0,0 @@
# Projekt-Dokumentation: CASPOS Webshop
Diese Datei beschreibt die Architektur, das Datenmodell, die Kernprozesse und das Sicherheitskonzept des CASPOS Webshops.
---
## 1. Projekt-Überblick
Der CASPOS Webshop ist ein B2B-Portal für Vertriebspartner. Partner können hier Software-Pakete und Zusatzmodule (für POS/Kassenlösungen) konfigurieren, Anfragen für ihre Endkunden erstellen und Lizenzen verwalten.
---
## 2. Technologie-Stack
- **Frontend/Backend**: Next.js 15+ (App Router, React Server Components, Server Actions).
- **Styling**: Tailwind CSS & Vanilla CSS (modernes Dark-Theme).
- **Datenbank & Auth**: Supabase (PostgreSQL, Row Level Security - RLS, Supabase Auth).
- **E-Mail-Versand**: SMTP-Integration für automatisierte Bestätigungen.
- **PDF-Generierung**: `@react-pdf/renderer` zur Erzeugung von Anfragebestätigungen als PDF.
- **Storage**: Supabase Storage (`invoices` Bucket) zur Archivierung der PDF-Dokumente.
---
## 3. Datenmodell & Beziehungen (Schema)
Das Datenbankschema besteht aus folgenden Tabellen im Schema `public`:
### `companies` (Unternehmen)
- Repräsentiert die Partner-Unternehmen (Retailer).
- `id` (UUID, Primary Key)
- `name` (TEXT)
- `street`, `zip`, `city`, `email` (Adressdaten)
### `users` (Systembenutzer)
- Erweitert die Authentifizierungsdaten aus `auth.users`.
- `id` (UUID, References `auth.users(id)`)
- `role` (TEXT, standardmäßig `'partner'`, oder `'admin'`)
- `company_id` (UUID, References `public.companies(id)`)
### `profiles` (Benutzerprofile)
- Stammdaten der einzelnen Benutzer (Ansprechpartner).
- `id` (UUID, References `auth.users(id)`)
- `first_name`, `last_name`, `email` etc.
### `end_customers` (Endkunden)
- Die Endkunden, für die die Partner Lizenzen bestellen.
- `id` (UUID, Primary Key)
- `partner_id` (UUID, References `public.companies(id)`) <-- *Direkte Zuordnung zur Firma des Partners*
- `company_name` (TEXT)
- `first_name`, `last_name`, `street`, `zip`, `city`, `email` (Stammdaten)
- `bank_iban`, `bank_bic`, `bank_name`, `bank_owner` (Bankdaten)
- `is_anonymized` (BOOLEAN) <-- *Für DSGVO-Löschung*
### `products` (Katalogprodukte)
- Hauptlösungen (z.B. Basic-Kasse, Backoffice).
- `id`, `name`, `base_price`, `tax_rate`, `billing_interval` (`one_time` / `monthly`).
- `requirements` (UUID[] von anderen Produkten)
- `exclusions` (UUID[] von inkompatiblen Produkten)
### `product_modules` (Zusatzmodule)
- Optionale Erweiterungen für Produkte.
- `id`, `product_id` (References `products`), `name`, `price`, `has_quantity` (BOOLEAN).
- `requirements` (UUID[] von Modulen)
- `exclusions` (UUID[] von Modulen)
### `orders` (Bestellungen / Anfragen)
- Gespeicherte Snapshots von Konfigurationen.
- `id` (UUID, Primary Key)
- `user_id` (UUID, References `auth.users(id)`) <-- *Ersteller der Bestellung*
- `order_number` (TEXT, Format: `AE-YYYY-NNNNN`)
- `order_hash` (TEXT, Idempotenz-Guard gegen Doppelübermittlung)
- `end_customer_id` (UUID, References `end_customers(id)`)
- `total_price` (DECIMAL)
- `customer_data` (JSONB) <-- *Eingefrorener Snapshot des Endkunden*
- `order_data` (JSONB) <-- *Eingefrorener Snapshot der Produktkonfiguration*
- `pdf_url` (TEXT, Link zur PDF-Rechnung im Storage)
- `status` (TEXT: `'pending'`, `'active'`, `'completed'`, `'cancelled'`)
---
## 4. Kernprozesse
```mermaid
graph TD
A[Partner loggt sich ein] --> B{Firma zugewiesen?}
B -- Nein --> C[Fehlermeldung: Kein Zutritt zum Wizard]
B -- Ja --> D[Wizard öffnen / Konfigurieren]
D --> E[Endkunden auswählen/anlegen]
E --> F[Produkte & Module wählen]
F --> G{Validierung erfolgreich?}
G -- Nein --> H[Validierungsfehler anzeigen]
G -- Ja --> I[Bestellung absenden]
I --> J[Snapshot einfrieren & in DB speichern]
J --> K[Rechnungs-PDF generieren & in Storage laden]
K --> L[E-Mail mit PDF an Partner senden]
L --> M[Bestellung in der Übersicht anzeigen]
```
### A. Partner- & Unternehmens-Hierarchie
1. Ein neu registrierter User hat die Rolle `partner` und ist zunächst keiner Firma zugeordnet.
2. Der Administrator ordnet den User in der Admin-Oberfläche (`/admin/users`) einem Unternehmen (`companies`) zu.
3. Nur wenn der User einer Firma zugewiesen ist (oder Admin ist), kann er Endkunden anlegen und Bestellungen aufgeben.
### B. Endkunden-Verwaltung (`/my-customers`)
- **Firmenweite Sicht**: Da `end_customers.partner_id` auf `companies.id` verweist, sehen alle Mitarbeiter desselben Unternehmens dieselben Endkunden.
- **DSGVO Anonymisierung**: Endkunden können unwiderruflich anonymisiert werden. Dabei werden sensible Daten mit `[GELÖSCHT]` überschrieben und `is_anonymized` auf `true` gesetzt. In existierenden Bestellungen (`orders.customer_data`) bleiben die Daten zu steuerlichen Zwecken unverändert.
### C. Bestell-Wizard & Validierung (`/order`)
- **Kategorie-Pflichten**: Wenn eine Produktkategorie als `is_required` definiert ist, muss ein Produkt ausgewählt werden.
- **Abhängigkeiten (Requirements)**: Ein Modul oder Produkt kann andere Module/Produkte voraussetzen (z.B. Modul B erfordert Modul A). Das System prüft dies client- und serverseitig.
- **Ausschlüsse (Exclusions)**: Inkompatible Produkte oder Module blockieren sich gegenseitig.
- **Snapshot-Architektur**: Sobald eine Bestellung aufgegeben wird, werden die Kundendaten (`CustomerSnapshot`) und Produktkonfigurationen (`OrderSnapshot` samt Preisen und Modulversionen) in `orders` als JSONB-Snapshots eingefroren. Preisänderungen im Katalog haben keinen Einfluss auf bestehende Bestellungen.
### D. Bestellungs-Verwaltung & Statusübergang
- **Sichtbarkeit**: Partner sehen in `/my-orders` alle Bestellungen aller Mitarbeiter ihrer Firma.
- **Bearbeiten**: Unvollständige Bestellungen (`status != 'completed'`) können von jedem Mitarbeiter der jeweiligen Firma nachträglich editiert werden.
- **Admin-Workflow**: Admins sehen in `/admin/orders` alle Bestellungen global, können den Status ändern (z.B. von *Eingegangen* auf *In Bearbeitung* oder *Abgeschlossen*) und PDF-Rechnungen manuell herunterladen.
- **Statusänderungs-Mails**: Bei jedem Statusübergang wird automatisch eine Benachrichtigungs-E-Mail an den Besteller geschickt.
---
## 5. Sicherheitskonzept (RLS - Row Level Security)
RLS ist auf Datenbankebene in Postgres implementiert und erzwingt Datenisolierung:
- **Unternehmen (`companies`)**: Authentifizierte Benutzer dürfen nur die Unternehmen lesen.
- **Endkunden (`end_customers`)**:
- `USING (partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Endkunden, deren `partner_id` mit der `company_id` des angemeldeten Benutzers übereinstimmt.
- **Bestellungen (`orders`)**:
- `USING (company_id = (SELECT company_id FROM public.users WHERE id = auth.uid()))`
- Partner sehen und modifizieren nur Bestellungen, die ihrer Company zugewiesen sind (sie müssen einer Company zugeordnet sein).
- Jede Bestellung wird bei der Erstellung automatisch der Company des Erstellers zugewiesen.
- Admins haben uneingeschränkten Zugriff und können Bestellungen nachträglich anderen Companies zuweisen.
- **Lizenzen (`licenses`)**:
- Partner sehen nur Lizenzen von Endkunden, die ihrer Company zugeordnet sind.
---
## Kommentarbereich
*Bitte nutze diesen Bereich oder füge Zeilenkommentare hinzu, um Änderungen oder Ergänzungen vorzuschlagen.*
- **Kommentar 1**:
- **Kommentar 2**:

View File

@@ -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

View File

@@ -0,0 +1,314 @@
'use server'
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
import { getProducts, getCategories } from '@/lib/actions/products';
import { sendMail } from '@/utils/mail';
import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
const rand = Math.floor(10000 + Math.random() * 90000);
return `${prefix}-${year}-${rand}`;
}
function hashOrderPayload(endCustomerId: string, items: any[]): string {
const str = endCustomerId + JSON.stringify(items);
return createHash('sha256').update(str).digest('hex');
}
// Asynchroner Hintergrund-Worker für Post-Processing (PDF-Erstellung und E-Mail)
async function triggerPostProcessing(orderId: string, supabase: any, customerSnapshot: any, orderSnapshot: any, type: string, email: string) {
try {
const { data: order, error } = await supabase
.from('orders')
.select('*')
.eq('id', orderId)
.single();
if (error || !order) {
console.error(`Post-Processing failed: Order ${orderId} not found`);
return;
}
// PDF generieren
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
);
const fileName = `ab_${order.id}.pdf`;
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
if (!uploadError) {
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
} else {
console.error(`PDF Upload failed for order ${orderId}:`, uploadError);
}
// E-Mail versenden
if (email) {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
const taxRate = orderSnapshot.tax_rate ?? 19;
const items = orderSnapshot.items ?? [];
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
let totalDetailsText = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
} else if (oneTimeNet > 0) {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
} else {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
}
let totalDetailsHtml = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsHtml = `
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
} else if (oneTimeNet > 0) {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
`;
} else {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
}
const itemsSection = buildEmailItemsSection(items);
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number,
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
await sendMail({
to: email,
subject: `Anfragebestätigung ${order.order_number}`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
}
]
});
}
} catch (err) {
console.error(`Error in post-processing for order ${orderId}:`, err);
}
}
export async function checkoutAction(params: {
items: any[];
endCustomerId: string;
customerProfile?: any;
}) {
const { items, endCustomerId, customerProfile } = params;
const supabase = await createClient();
// 1. Session & Auth Check
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Not authenticated');
}
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single();
const isAdminUser = dbUser?.role === 'admin';
if (!isAdminUser && !dbUser?.company_id) {
throw new Error('Kein Unternehmen zugewiesen. Zugriff verweigert.');
}
// 2. Idempotency Guard
const orderHash = hashOrderPayload(endCustomerId, items);
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
const { data: existingOrders } = await supabase
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('order_hash', orderHash)
.gte('created_at', fiveMinutesAgo);
if (existingOrders && existingOrders.length > 0) {
return { success: true, orderIds: existingOrders.map(o => o.id), duplicated: true };
}
// Fetch End Customer Stammdaten
const { data: endCustomer, error: customerErr } = await supabase
.from('end_customers')
.select('*')
.eq('id', endCustomerId)
.single();
if (customerErr || !endCustomer) {
throw new Error('Endkunde nicht gefunden.');
}
const customerSnapshot = buildCustomerSnapshot(customerProfile || {}, endCustomer);
const products = await getProducts();
const categories = await getCategories();
// 3. Checkout Split
const purchaseItems = items.filter(
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
);
const subscriptionItems = items.filter(
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
);
const orderIds: string[] = [];
const createOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
if (groupItems.length === 0) return;
const orderItemsList: any[] = [];
let total = 0;
for (const item of groupItems) {
const itemSnapshot = buildOrderSnapshot(
item.selections,
products,
categories,
type === 'purchase' ? 'one_time' : 'monthly',
item.moduleQuantities
);
const itemsWithDevice = itemSnapshot.items.map(i => ({
...i,
device_name: item.deviceName
}));
orderItemsList.push(...itemsWithDevice);
total += itemSnapshot.total;
}
const orderSnapshot = {
schema_version: 1,
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
items: orderItemsList,
total: total,
tax_rate: 19,
tax_amount: Math.round(total * 0.19 * 100) / 100,
subtotal: Math.round((total / 1.19) * 100) / 100
};
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
const { data: order, error: orderError } = await supabase
.from('orders')
.insert([{
user_id: user.id,
company_id: dbUser?.company_id || null,
order_number: orderNumber,
order_hash: orderHash,
end_customer_id: endCustomerId,
total_price: total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
type: type,
payment_method: paymentMethod,
status: 'pending'
}])
.select()
.single();
if (orderError) throw orderError;
orderIds.push(order.id);
// Entkoppeltes Post-Processing (asynchroner Aufruf)
if (user.email) {
Promise.resolve().then(() => {
triggerPostProcessing(order.id, supabase, customerSnapshot, orderSnapshot, type, user.email!);
});
}
};
await createOrderGroup(purchaseItems, 'purchase', 'invoice');
await createOrderGroup(subscriptionItems, 'subscription', 'sepa');
return { success: true, orderIds, duplicated: false };
}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users } from 'lucide-react'
import { Building2, Plus, Trash2, Loader2, Pencil, Mail, MapPin, Search, Users, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCompanies, createCompany, updateCompany, deleteCompany } from '@/lib/actions/companies'
import { getCompanies, createCompany, updateCompany, deleteCompany, syncCompaniesFromLicServer, syncCustomersFromLicServer } from '@/lib/actions/companies'
export default function CompaniesPage() {
const [companies, setCompanies] = useState<any[]>([])
@@ -32,6 +32,28 @@ export default function CompaniesPage() {
const [createError, setCreateError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [syncing, setSyncing] = useState(false)
const [statusMsg, setStatusMsg] = useState<string | null>(null)
const [statusType, setStatusType] = useState<'success' | 'error' | ''>('')
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [deleteConfirmName, setDeleteConfirmName] = useState('')
const handleSync = async () => {
setSyncing(true)
setStatusMsg(null)
try {
const resCompanies = await syncCompaniesFromLicServer()
const resCustomers = await syncCustomersFromLicServer()
setStatusMsg(`${resCompanies.count} Partner und ${resCustomers.count} Kunden erfolgreich vom LicServer importiert.`)
setStatusType('success')
loadCompanies()
} catch (err: any) {
setStatusMsg(`Fehler beim Import: ${err.message || err}`)
setStatusType('error')
} finally {
setSyncing(false)
}
}
@@ -90,18 +112,7 @@ export default function CompaniesPage() {
const handleDelete = async (id: string, firmName: string) => {
if (!confirm(`Firma "${firmName}" wirklich löschen? Alle zugewiesenen Benutzer werden getrennt.`)) return
setDeletingId(id)
try {
await deleteCompany(id)
loadCompanies()
} catch (err) {
console.error('Fehler beim Löschen:', err)
} finally {
setDeletingId(null)
}
}
const filteredCompanies = companies.filter((c) => {
const nameMatch = c.name?.toLowerCase().includes(searchQuery.toLowerCase())
@@ -123,14 +134,34 @@ export default function CompaniesPage() {
Verwalten Sie Firmen, deren Lieferadresse und weisen Sie Partner zu.
</p>
</div>
<Button
onClick={() => handleOpenDialog()}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
</Button>
<div className="flex items-center gap-3">
<Button
onClick={handleSync}
disabled={syncing}
variant="outline"
className="border-slate-200 dark:border-white/10 text-slate-900 dark:text-white"
>
{syncing ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Partner importieren
</Button>
<Button
onClick={() => handleOpenDialog()}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
<Plus className="w-4 h-4 mr-2" /> Firma anlegen
</Button>
</div>
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{statusMsg}
</div>
)}
{/* Firmen-Tabelle */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm">
<div className="relative mb-6 max-w-sm">
@@ -214,11 +245,14 @@ export default function CompaniesPage() {
>
<Pencil className="h-4 w-4" />
</Button>
<Button
<Button
variant="ghost"
size="icon"
disabled={deletingId === company.id}
onClick={() => handleDelete(company.id, company.name)}
onClick={() => {
setDeleteConfirmId(company.id)
setDeleteConfirmName(company.name)
}}
className="h-8 w-8 text-red-500 hover:text-red-400 hover:bg-red-500/10"
title="Firma löschen"
>
@@ -329,6 +363,44 @@ export default function CompaniesPage() {
</DialogContent>
</Dialog>
{/* Dialog: Delete Confirmation */}
<Dialog open={deleteConfirmId !== null} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
<DialogContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogHeader>
<DialogTitle>Firma löschen</DialogTitle>
<DialogDescription className="text-slate-500 dark:text-slate-400">
Möchten Sie die Firma <strong>{deleteConfirmName}</strong> wirklich löschen? Alle zugewiesenen Benutzer werden getrennt. Dieser Schritt kann nicht rückgängig gemacht werden.
</DialogDescription>
</DialogHeader>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={() => setDeleteConfirmId(null)}>
Abbrechen
</Button>
<Button
type="button"
onClick={async () => {
if (deleteConfirmId) {
setDeletingId(deleteConfirmId)
const targetId = deleteConfirmId
setDeleteConfirmId(null)
try {
await deleteCompany(targetId)
loadCompanies()
} catch (err) {
console.error('Fehler beim Löschen:', err)
} finally {
setDeletingId(null)
}
}
}}
className="bg-red-600 hover:bg-red-700 text-white font-semibold"
>
Löschen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)

View File

@@ -4,8 +4,11 @@
import { useState, useEffect } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Download, Upload, Database, AlertTriangle, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Download, Upload, Database, AlertTriangle, Loader2, KeyRound, Server, CheckCircle2, XCircle, Eye, EyeOff, Wifi } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { saveLicServerConfig, testLicServerConnection } from '@/lib/actions/licserver-config';
import { useRouter } from 'next/navigation';
export default function AdminSettings() {
@@ -16,26 +19,58 @@ export default function AdminSettings() {
const [statusType, setStatusType] = useState<'success' | 'error' | 'info' | ''>('');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [loading, setLoading] = useState(true);
// LicServer Config State
const [licUrl, setLicUrl] = useState('');
const [licKey, setLicKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [licSaving, setLicSaving] = useState(false);
const [licTesting, setLicTesting] = useState(false);
const [licStatus, setLicStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [licMsg, setLicMsg] = useState('');
const [licMsgType, setLicMsgType] = useState<'success' | 'error' | ''>('');
const router = useRouter();
useEffect(() => {
async function checkAccess() {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (!userData || userData.role === 'verwaltung') {
router.push('/admin');
} else {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/auth/login');
return;
}
const { data: userData, error: userError } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single();
if (userError || !userData || userData.role === 'verwaltung') {
router.push('/admin');
return;
}
const state = localStorage.getItem('demo_banner_disabled') !== 'true';
setDemoActive(state);
// Load current LicServer config from DB
try {
const { data: licRow } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single();
if (licRow) {
setLicUrl(licRow.licserver_base_url || '');
setLicKey(licRow.licserver_api_key || '');
}
} catch (dbErr) {
console.error("Fehler beim Laden der LicServer-Einstellungen:", dbErr);
}
} catch (err) {
console.error("Fehler bei checkAccess in Einstellungen:", err);
} finally {
setLoading(false);
}
}
@@ -55,7 +90,7 @@ export default function AdminSettings() {
try {
const res = await fetch('/api/admin/db-backup');
if (!res.ok) throw new Error('Export fehlgeschlagen');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -149,11 +184,10 @@ export default function AdminSettings() {
</div>
{statusMsg && (
<div className={`p-4 rounded-lg text-sm border ${
statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
<div className={`p-4 rounded-lg text-sm border ${statusType === 'success' ? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400' :
statusType === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
'bg-blue-500/10 border-blue-500/20 text-blue-600 dark:text-blue-400'
}`}>
{statusMsg}
</div>
)}
@@ -170,8 +204,8 @@ export default function AdminSettings() {
Lädt alle Kategorien, Produkte, Module, Firmen, Endkunden, Bestellungen und SMTP-Einstellungen als ZIP-Datei herunter.
</p>
</div>
<Button
onClick={handleExport}
<Button
onClick={handleExport}
disabled={exporting || importing}
className="w-full bg-primary hover:bg-primary/90 text-white"
>
@@ -200,9 +234,9 @@ export default function AdminSettings() {
<div className="space-y-3">
<div className="relative border border-dashed border-slate-200 dark:border-white/10 rounded-lg p-3 hover:bg-slate-100/50 dark:hover:bg-white/5 transition">
<input
type="file"
accept=".zip"
<input
type="file"
accept=".zip"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
disabled={exporting || importing}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
@@ -216,8 +250,8 @@ export default function AdminSettings() {
</div>
</div>
<Button
onClick={handleImport}
<Button
onClick={handleImport}
disabled={!selectedFile || exporting || importing}
variant="secondary"
className="w-full border border-slate-200 dark:border-white/10"
@@ -232,6 +266,125 @@ export default function AdminSettings() {
</div>
</div>
</div>
{/* LicServer Konfiguration */}
<div className="p-5 bg-white dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-white/10 shadow-sm space-y-5">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<KeyRound className="w-5 h-5 text-violet-500" />
LicServer Konfiguration
</h2>
<p className="text-slate-500 dark:text-slate-400 text-xs mt-1">
Verbindungseinstellungen zum CASPOS Lizenzserver.
</p>
</div>
{/* Status Message */}
{licMsg && (
<div className={`p-3 rounded-lg text-sm border ${licMsgType === 'success'
? 'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400'
: 'bg-destructive/10 border-destructive/20 text-destructive'
}`}>
{licMsg}
</div>
)}
{/* Connection Test Result */}
{licStatus && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm border ${licStatus.ok
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400'
: 'bg-amber-500/10 border-amber-500/20 text-amber-600 dark:text-amber-400'
}`}>
{licStatus.ok
? <CheckCircle2 className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />}
{licStatus.message}
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="lic-url" className="text-xs font-medium flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-slate-400" /> Server URL
</Label>
<Input
id="lic-url"
value={licUrl}
onChange={e => setLicUrl(e.target.value)}
placeholder="http://192.168.178.174:9980"
className="text-sm font-mono"
/>
<p className="text-[10px] text-slate-400">URL des CASPOS Lizenzservers</p>
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="lic-key" className="text-xs font-medium flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-slate-400" /> API-Key
</Label>
<div className="relative">
<Input
id="lic-key"
type={showKey ? 'text' : 'password'}
value={licKey}
onChange={e => setLicKey(e.target.value)}
placeholder="Ihr X-Api-Key"
className="text-sm font-mono pr-10"
/>
<button
type="button"
onClick={() => setShowKey(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
aria-label={showKey ? 'Key verbergen' : 'Key anzeigen'}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-1">
<Button
id="lic-save-btn"
disabled={licSaving || licTesting}
onClick={async () => {
setLicSaving(true);
setLicMsg('');
setLicStatus(null);
const res = await saveLicServerConfig(licUrl, licKey);
setLicMsgType(res.success ? 'success' : 'error');
setLicMsg(res.success ? 'Konfiguration gespeichert.' : (res.error || 'Fehler beim Speichern'));
setLicSaving(false);
}}
className="bg-violet-600 hover:bg-violet-500 text-white"
>
{licSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <KeyRound className="w-4 h-4 mr-2" />}
Speichern
</Button>
<Button
id="lic-test-btn"
variant="outline"
disabled={licSaving || licTesting}
onClick={async () => {
setLicTesting(true);
setLicStatus(null);
// Save first, then test
await saveLicServerConfig(licUrl, licKey);
const result = await testLicServerConnection();
setLicStatus(result);
setLicTesting(false);
}}
className="border-white/10"
>
{licTesting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wifi className="w-4 h-4 mr-2" />}
Verbindung testen
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import Link from 'next/link'
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2 } from 'lucide-react'
import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2, Sparkles } from 'lucide-react'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { AdminLogoutButton } from '@/components/admin/logout-menu-item'
import { DemoWrapper } from '@/components/DemoWrapper'
import { AdminNavLink } from '@/components/admin/admin-nav-link'
export default async function AdminLayout({
children,
@@ -39,55 +40,60 @@ export default async function AdminLayout({
<span className="font-bold text-xl tracking-tighter">Admin Panel</span>
</Link>
</div>
<nav className="flex-1 px-4 space-y-2 py-4">
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<AdminNavLink href="/admin">
<LayoutDashboard className="w-5 h-5" />
Dashboard
</Link>
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/products">
<Package className="w-5 h-5" />
Produkte
</Link>
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/categories">
<LayoutGrid className="w-5 h-5" />
Kategorien
</Link>
<Link href="/admin/orders" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/wysiwyg">
<Sparkles className="w-5 h-5" />
Order-Wizard
</AdminNavLink>
<AdminNavLink href="/admin/orders">
<ShoppingCart className="w-5 h-5" />
Bestellungen
</Link>
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/users">
<Users className="w-5 h-5" />
Benutzer
</Link>
</AdminNavLink>
<Link href="/admin/companies" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<AdminNavLink href="/admin/companies">
<Building2 className="w-5 h-5" />
Firmen
</Link>
</AdminNavLink>
{!isVerwaltung && (
<>
<div className="pt-2 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-600">Einstellungen</span>
</div>
<Link href="/admin/einstellungen" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
<AdminNavLink href="/admin/einstellungen">
<Settings className="w-5 h-5" />
Allgemein
</Link>
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/settings">
<Wrench className="w-5 h-5" />
SMTP
</Link>
<Link href="/admin/tools" className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 transition-all">
</AdminNavLink>
<AdminNavLink href="/admin/tools">
<Database className="w-5 h-5" />
DB-Tools
</Link>
</AdminNavLink>
</>
)}
</nav>
<div className="p-4 border-t border-slate-200 dark:border-white/5 space-y-4">
<AdminLogoutButton />
<div className="px-3 pt-2 flex items-center gap-2 border-t border-slate-200 dark:border-white/5 opacity-60">

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,48 @@
import { getProducts, getCategories } from '@/lib/actions/products'
import { WysiwygAdminClient } from './wysiwyg-client'
import { Edit, Sparkles } from 'lucide-react'
import { Suspense } from 'react'
export const dynamic = 'force-dynamic'
export default async function WysiwygAdminPage() {
return (
<div className="flex-1 space-y-8 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
<Edit className="w-8 h-8 text-primary" />
WYSIWYG Live-Editor
</h2>
<p className="text-slate-300">
Kategorien und Produkte visuell im Wizard-Layout bearbeiten.
</p>
</div>
</div>
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
<WysiwygDataWrapper />
</Suspense>
</div>
)
}
import { createClient } from '@/lib/supabase/server'
async function WysiwygDataWrapper() {
const products = await getProducts().catch(() => [])
const categories = await getCategories().catch(() => [])
const supabase = await createClient()
const { data: allModules } = await supabase
.from('product_modules')
.select('*')
.order('name', { ascending: true })
return (
<WysiwygAdminClient
initialProducts={products}
initialCategories={categories}
allModules={allModules || []}
/>
)
}

View File

@@ -0,0 +1,450 @@
'use client'
import { useState, useTransition } from 'react'
import { Category, Product, ProductModule } from '@/lib/types'
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory, addProductDependency, removeProductDependency } from '@/lib/actions/products'
import { InlineInput } from './components/inline-input'
import { ModulePopover } from './components/module-popover'
import { DependencyPopover } from './components/dependency-popover'
import { Trash2 } from 'lucide-react'
export function WysiwygAdminClient({
initialCategories,
initialProducts,
allModules
}: {
initialCategories: Category[]
initialProducts: Product[]
allModules: ProductModule[]
}) {
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
const [categories, setCategories] = useState(initialCategories)
const [products, setProducts] = useState(initialProducts)
const [isPending, startTransition] = useTransition()
const handleDeleteCategory = async (catId: string) => {
// Cascade-Check: check if there are products belonging to this category
const categoryProducts = products.filter(p => p.category_id === catId)
if (categoryProducts.length > 0) {
alert(`Diese Kategorie enthält noch ${categoryProducts.length} Produkte. Bitte lösche oder verschiebe diese zuerst!`)
return
}
if (!window.confirm('Möchten Sie diese Kategorie wirklich löschen?')) {
return
}
startTransition(async () => {
try {
await deleteCategory(catId)
setCategories(categories.filter(c => c.id !== catId))
} catch (e) {
console.error(e)
alert('Fehler beim Löschen der Kategorie.')
}
})
}
const handleDeleteProduct = async (prodId: string) => {
if (!window.confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
return
}
startTransition(async () => {
try {
await deleteProduct(prodId)
setProducts(products.filter(p => p.id !== prodId))
} catch (e) {
console.error(e)
alert('Fehler beim Löschen des Produkts.')
}
})
}
const getDependencyName = (depId: string) => {
const matchedProduct = products.find(p => p.id === depId)
if (matchedProduct) return matchedProduct.name
const matchedModule = allModules.find(m => m.id === depId)
if (matchedModule) return `${matchedModule.name} (Modul)`
return depId
}
// Filterung der Kategorien nach aktivem Modus
const filteredCategories = categories.filter(cat =>
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
)
// Filterung der Produkte nach aktivem Modus
const filteredProducts = products.filter(p =>
mode === 'purchase' ? p.billing_interval === 'one_time' : p.billing_interval === 'monthly'
)
const handleAddCategory = async () => {
startTransition(async () => {
try {
const newCat = await createCategory({
name: 'Neue Kategorie',
description: 'Beschreibung hier...',
sort_order: categories.length + 1,
is_required: false,
allow_multiselect: false,
free_items_limit: 0,
preselect: false,
show_in_kauf: mode === 'purchase',
show_in_abo: mode === 'subscription',
resets_others: false
})
setCategories([...categories, newCat])
} catch (e) {
console.error(e)
}
})
}
const handleAddProduct = async (categoryId: string) => {
startTransition(async () => {
try {
const newProd = await createProduct({
name: 'Neues Produkt',
description: 'Produktbeschreibung...',
base_price: 19.99,
tax_rate: 19,
billing_interval: mode === 'purchase' ? 'one_time' : 'monthly',
category_id: categoryId,
show_in_kauf: mode === 'purchase',
show_in_abo: mode === 'subscription'
}, [])
setProducts([...products, newProd])
} catch (e) {
console.error(e)
}
})
}
const handleCategoryToggle = async (catId: string, fields: Partial<Category>) => {
startTransition(async () => {
try {
setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c))
await updateCategory(catId, fields)
} catch (e) {
console.error(e)
}
})
}
return (
<div className={`p-6 max-w-7xl mx-auto space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div className="flex justify-between items-center border-b border-white/10 pb-4">
<div className="flex space-x-4">
<button
onClick={() => setMode('purchase')}
className={`px-4 py-2 rounded-lg font-medium transition ${
mode === 'purchase' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
}`}
>
Kauf-Modus (Einmalig)
</button>
<button
onClick={() => setMode('subscription')}
className={`px-4 py-2 rounded-lg font-medium transition ${
mode === 'subscription' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
}`}
>
Abo-Modus (Monatlich)
</button>
</div>
{isPending && <span className="text-xs text-primary animate-pulse font-medium">Speichere Änderungen...</span>}
</div>
<div className="space-y-8">
{filteredCategories
.sort((a, b) => a.sort_order - b.sort_order)
.map(cat => {
const catProducts = filteredProducts.filter(p => p.category_id === cat.id)
return (
<div key={cat.id} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 shadow-xl">
<div className="flex justify-between items-start border-b border-white/5 pb-4">
<div className="w-full">
<InlineInput
value={cat.name}
onSave={async (newName) => {
await updateCategory(cat.id, { name: newName })
setCategories(categories.map(c => c.id === cat.id ? { ...c, name: newName } : c))
}}
className="text-2xl font-bold text-white block w-full"
/>
<InlineInput
value={cat.description || ''}
onSave={async (newDesc) => {
await updateCategory(cat.id, { description: newDesc })
setCategories(categories.map(c => c.id === cat.id ? { ...c, description: newDesc } : c))
}}
className="text-slate-400 text-sm mt-1 block w-full"
/>
{/* Kategorie Attribute (Toggles) */}
<div className="flex flex-wrap items-center gap-6 mt-3 text-xs text-slate-400">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.is_required}
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Pflichtauswahl</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.allow_multiselect}
onChange={(e) => {
const checked = e.target.checked
handleCategoryToggle(cat.id, {
allow_multiselect: checked,
...(checked ? { preselect: false } : {})
})
}}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Mehrfachauswahl</span>
</label>
{!cat.allow_multiselect && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.preselect}
onChange={(e) => handleCategoryToggle(cat.id, { preselect: e.target.checked })}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Vorauswahl</span>
</label>
)}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.resets_others}
onChange={(e) => handleCategoryToggle(cat.id, { resets_others: e.target.checked })}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Rest zurücksetzen</span>
</label>
{cat.allow_multiselect && (
<div className="flex items-center gap-2">
<span>Freie Artikel Limit:</span>
<input
type="number"
min={0}
value={cat.free_items_limit}
onChange={(e) => handleCategoryToggle(cat.id, { free_items_limit: parseInt(e.target.value) || 0 })}
className="w-12 rounded border-white/10 bg-white/5 text-white focus:ring-0 focus:ring-offset-0 px-1 py-0.5 text-center"
/>
</div>
)}
</div>
</div>
<button
onClick={() => handleDeleteCategory(cat.id)}
className="p-2 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded-lg transition-all ml-4 flex-shrink-0"
title="Kategorie löschen"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{catProducts.map(prod => (
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 relative group flex flex-col justify-between">
<div className="space-y-2">
<div className="flex justify-between items-start gap-2">
<div className="flex-1">
<InlineInput
value={prod.name}
onSave={async (newName) => {
await updateProduct(prod.id, { name: newName }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, name: newName } : p))
}}
className="font-semibold text-white block"
/>
</div>
<button
onClick={() => handleDeleteProduct(prod.id)}
className="p-1 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Produkt löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<InlineInput
value={prod.description || ''}
onSave={async (newDesc) => {
await updateProduct(prod.id, { description: newDesc }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, description: newDesc } : p))
}}
className="text-xs text-slate-400 block"
/>
<div className="text-primary font-mono text-sm flex items-center space-x-1">
<InlineInput
value={prod.base_price.toString()}
type="number"
onSave={async (newPrice) => {
const priceNum = parseFloat(newPrice) || 0
await updateProduct(prod.id, { base_price: priceNum }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, base_price: priceNum } : p))
}}
className="inline-block"
/>
<span></span>
</div>
{/* Modul-Badges */}
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Module:</span>
<div className="flex flex-wrap gap-1">
{(prod.modules || []).length > 0 ? (
(prod.modules || []).map((m) => (
<span key={m.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-medium">
{m.name}
<button
onClick={async () => {
const updatedModules = (prod.modules || []).filter(mod => mod.id !== m.id)
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules } : p))
await updateProduct(prod.id, {}, updatedModules)
}}
className="text-slate-500 hover:text-white font-bold transition-colors ml-0.5"
title="Modul entfernen"
>
×
</button>
</span>
))
) : (
<span className="text-[10px] text-slate-500 italic">Keine Module</span>
)}
</div>
</div>
{/* Requirements */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Benötigt:</span>
<DependencyPopover
currentProductId={prod.id}
assignedIds={prod.requirements || []}
allProducts={products}
allModules={allModules}
onAddDependency={async (depId) => {
const newReqs = [...(prod.requirements || []), depId]
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
await addProductDependency(prod.id, 'requirements', depId)
}}
/>
</div>
<div className="flex flex-wrap gap-1">
{(prod.requirements || []).length > 0 ? (
(prod.requirements || []).map((reqId) => (
<span key={reqId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-green-500/10 text-green-400 border border-green-500/20 font-medium animate-fade-in">
{getDependencyName(reqId)}
<button
onClick={async () => {
const newReqs = (prod.requirements || []).filter(id => id !== reqId)
setProducts(products.map(p => p.id === prod.id ? { ...p, requirements: newReqs } : p))
await removeProductDependency(prod.id, 'requirements', reqId)
}}
className="text-green-600 hover:text-green-400 font-bold transition-colors ml-0.5"
>
×
</button>
</span>
))
) : (
<span className="text-[10px] text-slate-500 italic">Keine Anforderungen</span>
)}
</div>
</div>
{/* Exclusions */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Schließt aus:</span>
<DependencyPopover
currentProductId={prod.id}
assignedIds={prod.exclusions || []}
allProducts={products}
allModules={allModules}
onAddDependency={async (depId) => {
const newExcs = [...(prod.exclusions || []), depId]
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
await addProductDependency(prod.id, 'exclusions', depId)
}}
/>
</div>
<div className="flex flex-wrap gap-1">
{(prod.exclusions || []).length > 0 ? (
(prod.exclusions || []).map((exId) => (
<span key={exId} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium animate-fade-in">
{getDependencyName(exId)}
<button
onClick={async () => {
const newExcs = (prod.exclusions || []).filter(id => id !== exId)
setProducts(products.map(p => p.id === prod.id ? { ...p, exclusions: newExcs } : p))
await removeProductDependency(prod.id, 'exclusions', exId)
}}
className="text-red-600 hover:text-red-400 font-bold transition-colors ml-0.5"
>
×
</button>
</span>
))
) : (
<span className="text-[10px] text-slate-500 italic">Keine Ausschlüsse</span>
)}
</div>
</div>
</div>
{/* Modul hinzufügen Popover */}
<div className="mt-3 pt-2 border-t border-white/5">
<ModulePopover
assignedModules={prod.modules || []}
allModules={allModules}
onAddModule={async (mod) => {
const newMod = {
name: mod.name,
description: mod.description,
price: mod.price,
requirements: mod.requirements || [],
exclusions: mod.exclusions || [],
has_quantity: mod.has_quantity || false
}
const updatedModules = [...(prod.modules || []), newMod]
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules as any } : p))
await updateProduct(prod.id, {}, updatedModules)
}}
/>
</div>
</div>
))}
<button
onClick={() => handleAddProduct(cat.id)}
className="flex flex-col items-center justify-center p-4 border border-dashed border-white/20 hover:border-primary/50 rounded-xl text-slate-400 hover:text-primary transition h-full min-h-[140px]"
>
<span className="text-2xl mb-1">+</span>
<span className="text-xs font-medium">Produkt hinzufügen</span>
</button>
</div>
</div>
)
})}
<button
onClick={handleAddCategory}
className="w-full py-4 border border-dashed border-white/20 hover:border-primary/50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary transition"
>
<span className="text-xl mr-2">+</span>
<span className="font-medium">Neue Kategorie hinzufügen</span>
</button>
</div>
</div>
)
}

View 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 })
}
}

View 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 })
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -0,0 +1,297 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { getProducts, getCategories } from '@/lib/actions/products';
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform';
import { sendMail } from '@/utils/mail';
import { createHash } from 'crypto';
import { renderToBuffer } from '@react-pdf/renderer';
import React from 'react';
import { InvoicePDF } from '@/components/invoice-pdf';
import { getOrderEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates';
import { validateWizardSelections } from '@/lib/actions/validation';
function generateOrderNumber(prefix: 'BE' | 'AE' = 'AE'): string {
const year = new Date().getFullYear();
const rand = Math.floor(10000 + Math.random() * 90000);
return `${prefix}-${year}-${rand}`;
}
function hashOrderSnapshot(snapshot: object): string {
return createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
const { data: dbUser } = await supabase
.from('users')
.select('role, company_id')
.eq('id', user.id)
.single();
const isAdminUser = dbUser?.role === 'admin';
if (!isAdminUser && !dbUser?.company_id) {
return NextResponse.json({ error: 'Firma erforderlich' }, { status: 403 });
}
const body = await request.json();
const { items, customerProfile, endCustomerId, endCustomer } = body;
if (!items || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ error: 'Warenkorb leer' }, { status: 400 });
}
const products = await getProducts();
const categories = await getCategories();
// --- Backend-Validierung: alle Kategorie-Flags für jede Kasse prüfen ---
for (const item of items) {
if (item.selections) {
validateWizardSelections(item.selections, products, categories);
}
}
// Split items into purchase and subscription
const purchaseItems = items.filter(
(item: any) => item.billingInterval === 'one_time' || item.billingType === 'purchase'
);
const subscriptionItems = items.filter(
(item: any) => item.billingInterval === 'monthly' || item.billingType === 'subscription'
);
const createdOrders: any[] = [];
const processOrderGroup = async (groupItems: any[], type: 'purchase' | 'subscription', paymentMethod: string) => {
if (groupItems.length === 0) return;
// Build consolidated snapshots
// For the multi-item support, we combine the order snapshots of each item in the group
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer);
const orderItemsList: any[] = [];
let total = 0;
for (const item of groupItems) {
const itemSnapshot = buildOrderSnapshot(
item.selections,
products,
categories,
type === 'purchase' ? 'one_time' : 'monthly',
item.moduleQuantities
);
const itemsWithDevice = itemSnapshot.items.map(i => ({
...i,
device_name: item.deviceName
}));
orderItemsList.push(...itemsWithDevice);
total += itemSnapshot.total;
}
const orderSnapshot = {
schema_version: 1,
billing_cycle: type === 'purchase' ? 'one_time' : 'monthly',
items: orderItemsList,
total: total,
tax_rate: 19, // default
tax_amount: Math.round(total * 0.19 * 100) / 100,
subtotal: Math.round((total / 1.19) * 100) / 100
};
const orderHash = hashOrderSnapshot(orderSnapshot);
const orderNumber = generateOrderNumber(type === 'purchase' ? 'AE' : 'BE');
const { data: order, error: orderError } = await supabase
.from('orders')
.insert([{
user_id: user.id,
company_id: dbUser?.company_id || null,
order_number: orderNumber,
order_hash: orderHash,
end_customer_id: endCustomerId || null,
total_price: total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
type: type,
payment_method: paymentMethod,
status: 'pending'
}])
.select()
.single();
if (orderError) throw orderError;
const fileName = `ab_${order.id}.pdf`;
// Trigger PDF generation & storage upload
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
);
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true });
if (!uploadError) {
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id);
order.pdf_url = fileName;
}
} catch (pdfErr) {
console.error('PDF error for order ' + order.id, pdfErr);
}
// Trigger E-Mail notifications
if (user.email) {
try {
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE');
const taxRate = orderSnapshot.tax_rate ?? 19;
const items = orderSnapshot.items ?? [];
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
let totalDetailsText = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsText = `\nEinmaliger Betrag:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n\nMonatlicher Betrag:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
} else if (oneTimeNet > 0) {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- zzgl. ${taxRate}% MwSt: ${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}\n- Brutto Gesamtbetrag: ${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`;
} else {
totalDetailsText = `\nGesamtsumme:\n- Netto: ${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- zzgl. ${taxRate}% MwSt: ${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat\n- Brutto Gesamtbetrag: ${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat`;
}
let totalDetailsHtml = '';
if (oneTimeNet > 0 && monthlyNet > 0) {
totalDetailsHtml = `
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Einmaliger Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td colspan="2" style="padding: 8px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Monatlicher Betrag:</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">Netto:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; padding-left: 10px; font-weight: bold; color: #0f172a;">Brutto Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
} else if (oneTimeNet > 0) {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${oneTimeTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${oneTimeGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
</tr>
`;
} else {
totalDetailsHtml = `
<tr>
<td style="padding: 4px 0; color: #475569;">Netto-Gesamtbetrag:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyNet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #475569;">zzgl. ${taxRate}% MwSt:</td>
<td style="padding: 4px 0; text-align: right; color: #475569;">${monthlyTax.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">Gesamtbetrag (brutto):</td>
<td style="padding: 4px 0; text-align: right; font-weight: bold; color: #0f172a; border-top: 1px solid #e2e8f0; padding-top: 6px;">${monthlyGross.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} / Monat</td>
</tr>
`;
}
const itemsSection = buildEmailItemsSection(items);
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number,
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false);
const { data: bufferData, error: downloadError } = await supabase
.storage
.from('invoices')
.download(fileName);
const attachments: any[] = [];
if (!downloadError && bufferData) {
const buffer = Buffer.from(await bufferData.arrayBuffer());
attachments.push({
filename: `Anfragebestaetigung_${order.order_number}.pdf`,
content: buffer,
contentType: 'application/pdf',
});
}
await sendMail({
to: user.email,
subject: `Anfragebestätigung ${order.order_number}`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments
});
} catch (mailErr) {
console.error('Mail error for order ' + order.id, mailErr);
}
}
createdOrders.push(order);
};
await processOrderGroup(purchaseItems, 'purchase', 'invoice');
await processOrderGroup(subscriptionItems, 'subscription', 'sepa');
return NextResponse.json({ success: true, orders: createdOrders });
} catch (error: any) {
console.error('Checkout error:', error);
return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 });
}
}

View File

@@ -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>

View File

@@ -106,6 +106,6 @@
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-sky-500 to-blue-600 dark:from-primary dark:to-blue-400;
@apply text-blue-500 dark:text-blue-400;
}
}

View File

@@ -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>

View File

@@ -10,13 +10,17 @@ import type { Order } from '@/lib/types'
const statusLabel: Record<string, string> = {
pending: 'Eingegangen',
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const statusClass: Record<string, string> = {
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
active: 'bg-green-500/20 text-green-400 border-green-500/30',
completed: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
}
export default async function MyOrdersPage() {

View File

@@ -49,6 +49,14 @@ export default async function OrderSuccessPage({
const o = order as Order
const items = o.order_data?.items ?? []
const groupedItems: Record<string, typeof items> = {}
items.forEach(item => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
const taxRate = o.order_data?.tax_rate ?? 19
const oneTimeItems = items.filter((item) => item.billing_interval === 'one_time')
@@ -66,6 +74,7 @@ export default async function OrderSuccessPage({
pending: 'Eingegangen',
active: 'In Bearbeitung',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
return (
@@ -104,23 +113,30 @@ export default async function OrderSuccessPage({
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{/* Produkte */}
<div className="space-y-3">
{items.map((item) => (
<div key={item.product_id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
<span className="text-slate-400 text-xs ml-1">
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
</span>
</span>
{/* Produkte gruppiert nach Kasse */}
<div className="space-y-4">
{Object.entries(groupedItems).map(([deviceName, devItems]) => (
<div key={deviceName} className="space-y-2 border-b border-white/5 pb-3 last:border-0 last:pb-0">
<div className="bg-white/5 px-2 py-1 rounded text-xs text-primary font-bold">
Kasse: {deviceName}
</div>
{item.selected_modules.map((mod) => (
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
{devItems.map((item) => (
<div key={item.product_id} className="space-y-1 pl-2">
<div className="flex justify-between text-sm">
<span className="font-semibold text-white">{item.product_name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
<span className="text-slate-400 text-xs ml-1">
{item.billing_interval === 'one_time' ? 'einmalig' : '/ Monat'}
</span>
</span>
</div>
{item.selected_modules.map((mod) => (
<div key={mod.module_id} className="flex justify-between text-xs pl-4 text-slate-400">
<span>+ {mod.module_name}</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}</span>
</div>
))}
</div>
))}
</div>

View File

@@ -70,37 +70,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 overflow-hidden relative selection:bg-blue-500/30 selection:text-blue-200">
{/* Background Decorative Animated Blobs */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-[600px] pointer-events-none -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.15, 0.95, 1],
rotate: [0, 90, 180, 270, 360],
x: [0, 40, -30, 0],
y: [0, -20, 50, 0]
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[10%] left-[10%] w-[350px] sm:w-[500px] h-[350px] sm:h-[500px] bg-blue-600/15 blur-[120px] sm:blur-[160px] rounded-full"
/>
<motion.div
animate={{
scale: [1, 0.9, 1.1, 1],
rotate: [0, -90, -180, -270, -360],
x: [0, -30, 40, 0],
y: [0, 50, -30, 0]
}}
transition={{
duration: 25,
repeat: Infinity,
ease: "easeInOut"
}}
className="absolute -top-[5%] right-[15%] w-[300px] sm:w-[450px] h-[300px] sm:h-[450px] bg-purple-600/10 blur-[100px] sm:blur-[140px] rounded-full"
/>
</div>
{/* Hero Section */}
<section className="w-full pt-16 pb-12 md:pt-28 md:pb-20 px-4 relative flex flex-col items-center">
@@ -110,27 +80,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={containerVariants}
className="container max-w-6xl mx-auto flex flex-col items-center text-center space-y-8"
>
{/* Badge */}
<motion.div
variants={itemVariants}
className="inline-flex items-center gap-2 rounded-full bg-blue-500/10 border border-blue-500/20 px-4 py-1.5 text-xs sm:text-sm text-blue-400 font-semibold tracking-wide backdrop-blur-md shadow-[0_0_15px_rgba(59,130,246,0.1)]"
>
<motion.div
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
>
<Zap className="w-3.5 h-3.5 text-blue-400 fill-blue-400/20" />
</motion.div>
CASPOS Lizenz-Portal
</motion.div>
{/* Headline */}
<div className="space-y-4 max-w-4xl">
<motion.h1
variants={itemVariants}
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight bg-clip-text text-transparent bg-gradient-to-b from-white via-slate-100 to-slate-400"
className="text-4xl font-extrabold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl/tight text-white"
>
CASPOS Software <span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-500 via-indigo-400 to-purple-500 font-black">Lizenzen & Module</span>
CASPOS Software <span className="text-blue-500 font-black">Lizenzen & Module</span>
</motion.h1>
<motion.p
variants={itemVariants}
@@ -146,7 +103,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
className="flex flex-col sm:flex-row items-center justify-center gap-4 w-full max-w-md pt-4"
>
<Link href="/order" className="w-full sm:w-auto">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-bold transition-all duration-300 hover:scale-[1.02] bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-[0_0_25px_rgba(37,99,235,0.25)] hover:shadow-[0_0_35px_rgba(37,99,235,0.45)] border-none rounded-xl">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base font-semibold bg-blue-600 hover:bg-blue-500 text-white border border-blue-700/50 rounded-xl transition-all duration-200">
Lizenz anfragen <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
@@ -163,19 +120,17 @@ export function HomeClient({ initialUser }: HomeClientProps) {
variants={itemVariants}
className="w-full max-w-3xl pt-12"
>
<div className="relative p-6 rounded-2xl border border-slate-900/80 bg-slate-950/40 backdrop-blur-xl shadow-2xl">
{/* Card border glow decoration */}
<div className="absolute inset-0 rounded-2xl border border-blue-500/10 pointer-events-none" />
<p className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
<div className="relative p-6 rounded-2xl border border-slate-800 bg-slate-900/40 backdrop-blur-md shadow-lg">
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-5 text-left sm:text-center px-2">
Mein Dashboard Schnellzugriff
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Link href="/my-customers" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
@@ -192,9 +147,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<Link href="/my-orders" className="group">
<motion.div
whileHover={{ y: -4, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-900 bg-slate-950/60 hover:bg-slate-900/30 hover:border-blue-500/20 transition-all duration-300"
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
className="flex items-center justify-between p-4 rounded-xl border border-slate-800 bg-slate-950 hover:bg-slate-900 hover:border-slate-700 transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg bg-blue-500/10 text-blue-400 group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors">
@@ -223,10 +178,7 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<div className="absolute top-1/4 right-0 w-[400px] h-[400px] bg-indigo-600/5 blur-[120px] rounded-full -z-10 pointer-events-none" />
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 border border-indigo-500/20 px-3 py-1 rounded-full">
<Sparkles className="w-3 h-3" />CASPOS Sites
</div>
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-100 to-slate-400">
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-white">
Workspace-Zentrale
</h2>
<p className="text-slate-400 max-w-[600px] mx-auto text-sm sm:text-base">
@@ -237,11 +189,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Card 1 */}
<a href="https://support.teamzone.softengine.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-blue-500/10 rounded-xl text-blue-400 group-hover:bg-blue-500/20 group-hover:scale-110 transition-all duration-300">
<MessageSquare className="h-6 w-6" />
@@ -255,11 +205,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
{/* Card 2 */}
<a href="https://wiki.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-indigo-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-indigo-500/10 rounded-xl text-indigo-400 group-hover:bg-indigo-500/20 group-hover:scale-110 transition-all duration-300">
<BookOpen className="h-6 w-6" />
@@ -273,11 +221,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
{/* Card 3 */}
<a href="https://support.caspos.de" target="_blank" rel="noopener noreferrer"><motion.div
whileHover={{ y: -6 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-900 bg-slate-950/70 hover:bg-slate-900/40 hover:border-slate-800/80 transition-all duration-300 shadow-xl overflow-hidden"
whileHover={{ y: -4 }}
className="group relative flex flex-col p-6 rounded-2xl border border-slate-800 bg-slate-900/40 hover:bg-slate-900/80 hover:border-slate-700 transition-all duration-200 shadow-md overflow-hidden"
>
{/* Highlight line hover effect */}
<div className="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-purple-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="space-y-4 relative">
<div className="inline-flex p-3 bg-purple-500/10 rounded-xl text-purple-400 group-hover:bg-purple-500/20 group-hover:scale-110 transition-all duration-300">
<LifeBuoy className="h-6 w-6" />
@@ -296,17 +242,14 @@ export function HomeClient({ initialUser }: HomeClientProps) {
<section className="w-full py-16 md:py-24 border-t border-slate-900 bg-slate-950/30">
<div className="container max-w-5xl mx-auto px-4">
<div className="text-center space-y-3 mb-16">
<div className="inline-flex items-center gap-1.5 text-xs font-bold text-blue-400 uppercase tracking-widest bg-blue-500/10 border border-blue-500/20 px-3 py-1 rounded-full">
<Activity className="w-3 h-3" /> Ablauf
</div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center bg-clip-text text-transparent bg-gradient-to-r from-white via-slate-200 to-slate-400">
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-center text-white">
Wie funktioniert die Bereitstellung?
</h2>
</div>
<div className="relative">
{/* Connection line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] bg-gradient-to-r from-blue-500/10 via-blue-500/30 to-blue-500/10 z-0" />
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-[1px] border-t border-dashed border-slate-800 z-0" />
<div className="grid gap-8 md:grid-cols-3 relative z-10">
{/* Step 1 */}

View File

@@ -110,7 +110,7 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
<nav className="hidden md:flex gap-6 items-center">
<Link
className="text-sm font-medium text-slate-300 hover:text-primary transition-colors"
href="https://wiki.caspos.de"
href="https://support.teamzone.softengine.de"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -111,8 +111,8 @@ export function SetupWizard() {
{/* Step Indicator Header */}
<div className="flex items-center justify-between mb-8 px-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-gradient bg-gradient-to-r from-blue-400 to-indigo-400">CASPOS</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">Initialisierung</span>
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
</div>
<div className="flex gap-1.5">
{[1, 2, 3].map((s) => (

View 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>
)
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -61,6 +61,7 @@ const productSchema = z.object({
requirements: z.array(z.string()).default([]),
exclusions: z.array(z.string()).default([]),
modules: z.array(moduleSchema).default([]),
allow_update_discount: z.boolean().default(true),
})
type ProductFormValues = z.infer<typeof productSchema>
@@ -77,8 +78,32 @@ export function CreateProductDialog({
products?: Product[]
}) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const router = useRouter()
function handleOpenChange(isOpen: boolean) {
setOpen(isOpen)
if (isOpen) {
setStep(1)
}
}
async function nextStep() {
let isValid = false
if (step === 1) {
isValid = await form.trigger(['name', 'category_id', 'base_price'])
} else if (step === 2) {
isValid = await form.trigger('billing_interval')
} else if (step === 3) {
isValid = await form.trigger(['show_in_kauf', 'show_in_abo'])
} else if (step === 4) {
isValid = await form.trigger(['requirements', 'exclusions'])
}
if (isValid || step === 5) {
setStep(prev => Math.min(prev + 1, 5))
}
}
const otherProducts = products.filter(p => p.id !== product?.id)
const form = useForm<ProductFormValues>({
@@ -93,6 +118,7 @@ export function CreateProductDialog({
show_in_abo: product ? (product.show_in_abo ?? true) : true,
requirements: product?.requirements || [],
exclusions: product?.exclusions || [],
allow_update_discount: product ? (product.allow_update_discount ?? true) : true,
modules: product?.modules?.map(m => ({
id: m.id,
name: m.name,
@@ -134,7 +160,7 @@ export function CreateProductDialog({
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{children || (
<Button className="bg-primary hover:bg-primary/90">
@@ -142,7 +168,7 @@ export function CreateProductDialog({
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white shadow-2xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{product ? 'Produkt bearbeiten' : 'Neues Produkt erstellen'}
@@ -155,142 +181,209 @@ export function CreateProductDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-hidden flex-1 min-h-0">
<div className="flex-1 overflow-y-auto pr-4 space-y-6 py-4 min-h-0">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="category_id"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-1">
{[
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => field.onChange(opt.value)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
field.value === opt.value
? 'border-primary bg-primary/10 text-primary dark:text-white'
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
}`}
>
<span className="text-lg">{opt.icon}</span>
{opt.label}
</button>
))}
{/* Step Indicator */}
{!product && (
<div className="flex justify-between items-center mb-6 border-b border-slate-100 dark:border-white/5 pb-4">
{[
{ nr: 1, label: 'Allgemein' },
{ nr: 2, label: 'Abrechnung' },
{ nr: 3, label: 'Sichtbarkeit' },
{ nr: 4, label: 'Abhängigkeiten' },
{ nr: 5, label: 'Module' }
].map(s => (
<div key={s.nr} className="flex flex-col items-center gap-1 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors ${
step === s.nr
? 'bg-primary text-white'
: step > s.nr
? 'bg-green-500 text-white'
: 'bg-slate-200 dark:bg-slate-800 text-slate-500'
}`}>
{s.nr}
</div>
<FormMessage />
</FormItem>
)}
/>
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{s.label}</span>
</div>
))}
</div>
)}
{(product || step === 1) && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Produktname</FormLabel>
<FormControl>
<Input placeholder="z.B. Basic ERP" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Basispreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="show_in_kauf"
name="category_id"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
</div>
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Kategorie</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent className="bg-white dark:bg-slate-950 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white">
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="show_in_abo"
name="description"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung des Produkts" className="bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
{(product || step === 2) && (
<div className="space-y-6">
<FormField
control={form.control}
name="billing_interval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Abrechnungsmodell</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-1">
{[
{ value: 'one_time', label: 'Einmalig', icon: '💳' },
{ value: 'monthly', label: 'Monatlich', icon: '🔄' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => field.onChange(opt.value)}
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
field.value === opt.value
? 'border-primary bg-primary/10 text-primary dark:text-white'
: 'border-slate-200 dark:border-white/10 bg-slate-50 dark:bg-white/5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10'
}`}
>
<span className="text-lg">{opt.icon}</span>
{opt.label}
</button>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
{(product || step === 3) && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="show_in_kauf"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Kauf-Auswahl anzeigen</FormLabel>
<FormDescription className="text-xs text-slate-500">Produkt im Kauf-Zweig anzeigen</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="show_in_abo"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">In Abo-Auswahl anzeigen</FormLabel>
<FormDescription className="text-xs text-slate-500">Produkt im Abo-Zweig anzeigen</FormDescription>
</div>
</FormItem>
)}
/>
</div>
{form.watch('billing_interval') === 'one_time' && (
<FormField
control={form.control}
name="allow_update_discount"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-slate-900 dark:text-white font-medium cursor-pointer">Update-Rabatt erlauben</FormLabel>
<FormDescription className="text-xs text-slate-500">
Kunden erhalten für dieses Produkt den selektiven Update-Rabatt.
</FormDescription>
</div>
</FormItem>
)}
/>
)}
/>
</div>
)}
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
{(product || step === 4) && (
<div className="grid grid-cols-2 gap-4">
{/* Product Requirements */}
<FormField
@@ -384,33 +477,17 @@ export function CreateProductDialog({
)}
/>
</div>
)}
<Separator className="bg-slate-200 dark:bg-white/10" />
{product && <Separator className="bg-slate-200 dark:bg-white/10" />}
{(product || step === 5) && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<PlusCircle className="w-5 h-5 text-primary" />
Module
</h3>
<Button
type="button"
variant="outline"
size="sm"
className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
name: '',
price: 0,
is_required: false,
has_quantity: false,
description: '',
requirements: [],
exclusions: []
})}
>
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
</Button>
</div>
{fields.map((field, index) => {
@@ -423,79 +500,90 @@ export function CreateProductDialog({
.filter((_, idx) => idx !== index)
return (
<div key={field.id} className="p-4 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 space-y-4 relative group">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => remove(index)}
>
<X className="h-4 w-4" />
</Button>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Cloud Storage" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white">Aufpreis ()</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-slate-100 dark:bg-white/10 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white placeholder:text-slate-500" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div key={field.id} className="p-4 border border-slate-200 dark:border-white/10 rounded-xl bg-slate-50 dark:bg-white/5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.name`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white text-xs">Modulname</FormLabel>
<FormControl>
<Input placeholder="z.B. Zusatzlizenz" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`modules.${index}.price`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white text-xs">Preis (/Monat)</FormLabel>
<FormControl>
<Input type="number" step="0.01" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20 mt-6"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-4 border border-slate-200 dark:border-white/10 rounded-lg p-3 bg-slate-100 dark:bg-black/20">
{/* Requirements Selection Matrix */}
<FormField
control={form.control}
name={`modules.${index}.requirements`}
render={({ field: reqField }) => (
<div className="space-y-2">
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Benötigt Module</FormLabel>
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
{otherModules.length === 0 ? (
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
) : (
<div className="space-y-1.5">
<FormField
control={form.control}
name={`modules.${index}.description`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-slate-900 dark:text-white text-xs">Beschreibung</FormLabel>
<FormControl>
<Input placeholder="Kurze Beschreibung" className="bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-900 dark:text-white text-xs" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{otherModules.length > 0 && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name={`modules.${index}.requirements`}
render={({ field: mReqField }) => (
<FormItem className="flex flex-col gap-1">
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Erfordert Module</FormLabel>
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
<div className="space-y-1">
{otherModules.map(other => {
const checked = (reqField.value || []).includes(other.id)
const checked = (mReqField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`req-${index}-${other.id}`}
id={`mod-req-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = reqField.value || []
const currentVal = mReqField.value || []
if (checkedState) {
reqField.onChange([...currentVal, other.id])
mReqField.onChange([...currentVal, other.id])
} else {
reqField.onChange(currentVal.filter(id => id !== other.id))
mReqField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`req-${index}-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
htmlFor={`mod-req-${index}-${other.id}`}
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
>
{other.name}
</label>
@@ -503,44 +591,38 @@ export function CreateProductDialog({
)
})}
</div>
)}
</ScrollArea>
</div>
)}
/>
</ScrollArea>
</FormItem>
)}
/>
{/* Exclusions Selection Matrix */}
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field: exclField }) => (
<div className="space-y-2">
<FormLabel className="text-slate-900 dark:text-white text-xs font-semibold">Schließt aus</FormLabel>
<ScrollArea className="h-28 border border-slate-200 dark:border-white/5 rounded p-2 bg-slate-50 dark:bg-white/5">
{otherModules.length === 0 ? (
<p className="text-[10px] text-slate-500 italic">Keine anderen Module vorhanden.</p>
) : (
<div className="space-y-1.5">
<FormField
control={form.control}
name={`modules.${index}.exclusions`}
render={({ field: mExclField }) => (
<FormItem className="flex flex-col gap-1">
<FormLabel className="text-slate-900 dark:text-white text-[11px] font-medium">Schließt Module aus</FormLabel>
<ScrollArea className="h-24 border border-slate-200 dark:border-white/10 rounded p-2 bg-white dark:bg-slate-900">
<div className="space-y-1">
{otherModules.map(other => {
const checked = (exclField.value || []).includes(other.id)
const checked = (mExclField.value || []).includes(other.id)
return (
<div key={other.id} className="flex items-center space-x-2">
<Checkbox
id={`excl-${index}-${other.id}`}
id={`mod-excl-${index}-${other.id}`}
checked={checked}
onCheckedChange={(checkedState) => {
const currentVal = exclField.value || []
const currentVal = mExclField.value || []
if (checkedState) {
exclField.onChange([...currentVal, other.id])
mExclField.onChange([...currentVal, other.id])
} else {
exclField.onChange(currentVal.filter(id => id !== other.id))
mExclField.onChange(currentVal.filter(id => id !== other.id))
}
}}
/>
<label
htmlFor={`excl-${index}-${other.id}`}
className="text-xs text-slate-700 dark:text-slate-300 cursor-pointer select-none truncate block max-w-[180px]"
title={other.name}
htmlFor={`mod-excl-${index}-${other.id}`}
className="text-[10px] text-slate-650 dark:text-slate-350 cursor-pointer truncate block"
>
{other.name}
</label>
@@ -548,12 +630,12 @@ export function CreateProductDialog({
)
})}
</div>
)}
</ScrollArea>
</div>
)}
/>
</div>
</ScrollArea>
</FormItem>
)}
/>
</div>
)}
<div className="flex items-center gap-6">
<FormField
@@ -591,18 +673,73 @@ export function CreateProductDialog({
</div>
)
})}
{fields.length === 0 && (
<div className="text-center py-6 border-2 border-dashed border-slate-200 dark:border-white/5 rounded-lg text-slate-500 dark:text-slate-400 text-sm">
Noch keine Module hinzugefügt.
</div>
)}
<div className="flex justify-end pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-primary/50 text-primary hover:bg-primary/10"
onClick={() => append({
id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15),
name: '',
price: 0,
is_required: false,
has_quantity: false,
description: '',
requirements: [],
exclusions: []
})}
>
<Plus className="w-4 h-4 mr-2" /> Modul hinzufügen
</Button>
</div>
</div>
)}
</div>
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10">
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">Abbrechen</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">Speichern</Button>
<DialogFooter className="pt-4 border-t border-slate-200 dark:border-white/10 flex justify-between items-center sm:justify-between">
{product ? (
<>
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
Abbrechen
</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
Speichern
</Button>
</>
) : (
<>
<div className="flex gap-2">
{step > 1 ? (
<Button type="button" variant="outline" onClick={() => setStep(prev => prev - 1)}>
Zurück
</Button>
) : (
<Button type="button" variant="ghost" onClick={() => setOpen(false)} className="text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
Abbrechen
</Button>
)}
</div>
<div className="flex gap-2">
{step < 5 ? (
<Button type="button" onClick={nextStep} className="bg-primary hover:bg-primary/90 text-black font-medium">
Weiter
</Button>
) : (
<Button type="submit" className="bg-primary hover:bg-primary/90 text-white">
Speichern
</Button>
)}
</div>
</>
)}
</DialogFooter>
</form>
</Form>

View File

@@ -24,6 +24,7 @@ const statusLabel: Record<string, string> = {
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
};
const statusClass: Record<string, string> = {
@@ -31,6 +32,7 @@ const statusClass: Record<string, string> = {
active: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
completed: 'bg-green-500/20 text-green-400 border border-green-500/30',
cancelled: 'bg-red-500/20 text-red-400 border border-red-500/30',
rejected: 'bg-rose-500/20 text-rose-400 border border-rose-500/30',
};
export function OrdersTable({ initialOrders }: OrdersTableProps) {
@@ -43,7 +45,7 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
setOrders(initialOrders);
}, [initialOrders]);
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled') => {
const handleStatusChange = async (orderId: string, newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected') => {
setUpdatingId(orderId);
try {
await updateOrderStatus(orderId, newStatus);
@@ -126,6 +128,14 @@ export function OrdersTable({ initialOrders }: OrdersTableProps) {
>
Storniert
</Button>
<Button
variant={statusFilter === "rejected" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("rejected")}
className="text-xs border-rose-500/20 hover:bg-rose-500/10 text-rose-400"
>
Abgelehnt
</Button>
</div>
</div>

View File

@@ -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" />

View File

@@ -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

View File

@@ -1,6 +1,5 @@
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
// Create styles
const styles = StyleSheet.create({
page: {
padding: 50,
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
table: {
width: 'auto',
borderStyle: 'solid',
borderWidth: 1,
@@ -63,21 +61,16 @@ const styles = StyleSheet.create({
padding: 8,
textAlign: 'right',
},
tableCell: {
margin: 'auto',
marginTop: 5,
fontSize: 10,
},
total: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#000',
paddingTop: 10,
},
totalLabel: {
fontSize: 14,
fontSize: 11,
fontWeight: 'bold',
},
footer: {
@@ -88,34 +81,67 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: '#999',
fontSize: 8,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#eee',
paddingTop: 10,
},
sepaBox: {
marginTop: 15,
padding: 10,
backgroundColor: '#f9f9f9',
borderWidth: 1,
borderColor: '#ddd',
}
});
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
const items = orderSnapshot?.items ?? [];
const taxRate = orderSnapshot?.tax_rate ?? 19;
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
let oneTimeNet = 0;
let monthlyNet = 0;
items.forEach((item: any) => {
if (item.billing_interval === 'monthly') {
monthlyNet += item.base_price || 0;
} else {
oneTimeNet += item.base_price || 0;
}
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
// Module sind monatliche wiederkehrende Beträge (Abo/Miete)
monthlyNet += price;
});
});
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
const formattedPrice = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
const groupedItems: { [key: string]: any[] } = {};
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1';
if (!groupedItems[devName]) {
groupedItems[devName] = [];
}
groupedItems[devName].push(item);
});
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>CASPOS</Text>
<Text>Software Solutions</Text>
<Text>Die Kasse</Text>
</View>
<View style={styles.companyInfo}>
<Text>CASPOS Computerabrechnungssysteme GmbH</Text>
@@ -125,155 +151,127 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
</View>
<Text style={styles.title}>Anfragebestätigung</Text>
<Text style={styles.title}>
Anfragebestätigung: {order.order_number || order.id}
</Text>
<View style={styles.section}>
<Text style={styles.label}>Anfragender Kunde</Text>
<Text style={styles.label}>Kunde</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, color: '#666' }}>
Bank: {customer.bank_name || '-'} · IBAN: {customer.bank_iban} · BIC: {customer.bank_bic || '-'} · Inhaber: {customer.bank_owner || '-'}
</Text>
)}
</View>
<View style={styles.section}>
<Text style={styles.label}>Anfragedetails</Text>
<Text>Anfrage-Nummer: {order.order_number || order.id}</Text>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
<Text style={styles.label}>Update-Konditionen</Text>
<Text style={{ fontWeight: 'bold' }}>
{orderSnapshot.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: orderSnapshot.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
</Text>
{orderSnapshot.last_license_date && (
<Text style={{ marginTop: 2, color: '#666' }}>
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
</Text>
)}
</View>
)}
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
<View style={styles.tableColPrice}><Text style={{ fontWeight: 'bold' }}>Preis (netto)</Text></View>
</View>
{items.map((item: any, idx: number) => (
<View key={idx}>
<View style={styles.tableRow}>
<View style={styles.tableCol}>
<Text style={{ fontWeight: 'bold' }}>{item.product_name} ({item.category_name})</Text>
</View>
<View style={styles.tableColPrice}>
<Text>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</Text>
</View>
<View style={{ marginTop: 10, marginBottom: 15 }}>
{Object.entries(groupedItems).map(([deviceName, devItems], groupIdx) => (
<View key={groupIdx} style={{ marginBottom: 12, borderWidth: 1, borderColor: '#e2e8f0', borderRadius: 4, padding: 8, backgroundColor: '#f8fafc' }}>
<View style={{ borderBottomWidth: 1, borderBottomColor: '#cbd5e1', paddingBottom: 4, marginBottom: 6 }}>
<Text style={{ fontSize: 10, fontWeight: 'bold', color: '#1e3a8a' }}>
Kasse: {deviceName}
</Text>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
const hasQty = qty > 1;
return (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
</Text>
</View>
{devItems.map((item: any, idx: number) => (
<View key={idx} style={{ marginBottom: 6 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#334155' }}>
{item.product_name} ({item.category_name})
</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#0f172a' }}>
{formattedPrice(item.base_price)} {item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</Text>
</View>
);
})}
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
return (
<View key={mIdx} style={{ flexDirection: 'row', justifyContent: 'space-between', paddingLeft: 12, marginBottom: 1 }}>
<Text style={{ fontSize: 8, color: '#64748b' }}>
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
</Text>
<Text style={{ fontSize: 8, color: '#64748b' }}>
+{formattedPrice(price)} mtl.
</Text>
</View>
);
})}
</View>
))}
</View>
))}
</View>
<View style={styles.total}>
<View style={{ width: '50%' }}>
<View style={{ width: '75%' }}>
{oneTimeNet > 0 && (
<View style={{ marginBottom: monthlyNet > 0 ? 10 : 0 }}>
{monthlyNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Einmaliger Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<View style={{ marginBottom: 6 }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Einmalige Beträge:</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</Text>
<Text>{formattedPrice(oneTimeNet)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</Text>
<Text>{formattedPrice(oneTimeTax)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>
{monthlyNet > 0 ? 'Gesamtbetrag einmalig (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={styles.totalLabel}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
<Text>Gesamt einmalig (brutto):</Text>
<Text>{formattedPrice(oneTimeGross)}</Text>
</View>
</View>
)}
{oneTimeNet > 0 && monthlyNet > 0 && (
<View style={{ borderTopWidth: 1, borderTopColor: '#eee', marginVertical: 10 }} />
)}
{monthlyNet > 0 && (
<View>
{oneTimeNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Monatlicher Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<View style={{ marginTop: 4, paddingTop: 4, borderTopWidth: oneTimeNet > 0 ? 1 : 0, borderTopColor: '#e2e8f0' }}>
<Text style={{ fontSize: 9, fontWeight: 'bold', color: '#1e293b', marginBottom: 2 }}>Monatlich wiederkehrende Beträge:</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</Text>
<Text>{formattedPrice(monthlyNet)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</Text>
<Text>{formattedPrice(monthlyTax)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>
{oneTimeNet > 0 ? 'Gesamtbetrag monatlich (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={styles.totalLabel}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', fontWeight: 'bold' }}>
<Text>Gesamt monatlich (brutto):</Text>
<Text>{formattedPrice(monthlyGross)} / mtl.</Text>
</View>
</View>
)}
</View>
</View>
{isSubscription ? (
<View style={styles.sepaBox}>
<Text style={{ fontWeight: 'bold', marginBottom: 4 }}>SEPA-Lastschriftmandat</Text>
<Text style={{ fontSize: 8, color: '#555' }}>
Ich ermächtige die CASPOS GmbH, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von der CASPOS GmbH auf mein Konto gezogenen Lastschriften einzulösen. Das Mandat wird erst mit der finalen Aktivierung der Lizenzen durch den CASPOS-Support wirksam.
</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, fontSize: 8, fontWeight: 'bold' }}>
IBAN: {customer.bank_iban.replace(/(.{4})/g, '$1 ')} · Inhaber: {customer.bank_owner || 'Kunde'}
</Text>
)}
</View>
) : (
<View style={{ marginTop: 20 }}>
<Text style={{ fontSize: 9, color: '#555', fontStyle: 'italic' }}>
Dies ist eine unverbindliche Bestätigung Ihrer Anfrage. Ein verbindliches Angebot wird Ihnen in Kürze von unserem Vertriebsteam zugesendet.
</Text>
</View>
)}
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
</View>
</Page>
</Document>

View File

@@ -102,22 +102,22 @@ export function LoginForm({
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{errorParam === "gesperrt" && (
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
</p>
)}
{messageParam === "concurrent" && (
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Wird angemeldet..." : "Anmelden"}
</Button>
</div>
</form>
{errorParam === "gesperrt" && (
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
</p>
)}
{messageParam === "concurrent" && (
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
</p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Wird angemeldet..." : "Anmelden"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,480 @@
'use client'
import React, { useState, useTransition, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
KeyRound,
Search,
Loader2,
CheckCircle2,
AlertTriangle,
XCircle,
Building2,
User,
CalendarDays,
Package,
ShieldCheck,
Hash,
ChevronDown,
ChevronUp,
Fingerprint,
} from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { lookupLicenseFromLicServer } from '@/lib/actions/licserver-config'
// ─── MOCK DATA (until real LicServer API is wired up) ───────────────────────
const MOCK_LICENSES: Record<string, LicenseInfo> = {
'CAS-2023-PRO-00123': {
licenseKey: 'CAS-2023-PRO-00123',
status: 'active',
product: 'CAS genesisWorld Professional',
edition: 'Professional',
version: '14.1.2',
seats: 10,
customer: 'Mustermann GmbH',
contact: 'Max Mustermann',
issuedAt: '2023-04-01',
expiresAt: '2026-03-31',
maintenanceUntil: '2025-03-31',
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client'],
},
'CAS-2020-STD-00456': {
licenseKey: 'CAS-2020-STD-00456',
status: 'expired',
product: 'CAS genesisWorld Standard',
edition: 'Standard',
version: '12.0.0',
seats: 5,
customer: 'Beispiel AG',
contact: 'Erika Muster',
issuedAt: '2020-01-15',
expiresAt: '2023-01-14',
maintenanceUntil: '2022-01-14',
modules: ['CRM'],
},
'CAS-2024-ENT-00789': {
licenseKey: 'CAS-2024-ENT-00789',
status: 'active',
product: 'CAS genesisWorld Enterprise',
edition: 'Enterprise',
version: '15.0.0',
seats: 50,
customer: 'Tech Solutions GmbH & Co. KG',
contact: 'Julia Schneider',
issuedAt: '2024-01-01',
expiresAt: '2026-12-31',
maintenanceUntil: '2026-12-31',
modules: ['CRM', 'ERP-Link', 'Mobile Client', 'Web Client', 'AI Assistant', 'Analytics Pro'],
},
}
// ─── TYPES ───────────────────────────────────────────────────────────────────
export type LicenseStatus = 'active' | 'expired' | 'invalid' | 'suspended'
export interface LicenseInfo {
licenseKey: string
status: LicenseStatus
product: string
edition: string
version: string
seats: number
customer: string
contact: string
issuedAt: string
expiresAt: string
maintenanceUntil: string
modules: string[]
}
interface LicenseLookupPanelProps {
/** Called when a valid license is found so the wizard can prefill fields */
onLicenseResolved?: (info: LicenseInfo) => void
}
// ─── HELPERS ─────────────────────────────────────────────────────────────────
function statusConfig(status: LicenseStatus) {
switch (status) {
case 'active':
return {
label: 'Aktiv',
icon: <CheckCircle2 className="w-3.5 h-3.5" />,
cls: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
ring: 'border-emerald-500/30',
glow: 'shadow-emerald-500/10',
}
case 'expired':
return {
label: 'Abgelaufen',
icon: <AlertTriangle className="w-3.5 h-3.5" />,
cls: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
ring: 'border-amber-500/30',
glow: 'shadow-amber-500/10',
}
case 'suspended':
return {
label: 'Gesperrt',
icon: <XCircle className="w-3.5 h-3.5" />,
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
ring: 'border-red-500/30',
glow: 'shadow-red-500/10',
}
default:
return {
label: 'Ungültig',
icon: <XCircle className="w-3.5 h-3.5" />,
cls: 'bg-red-500/20 text-red-300 border-red-500/40',
ring: 'border-red-500/30',
glow: 'shadow-red-500/10',
}
}
}
function fmt(dateStr: string) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
}
// ─── LOOKUP (tries real LicServer API call, falls back to MOCK in dev) ──────────────────────
async function lookupLicense(key: string): Promise<LicenseInfo | null> {
try {
return await lookupLicenseFromLicServer(key)
} catch (err) {
console.error('Lookup failed, falling back to mock details:', err)
return MOCK_LICENSES[key.trim().toUpperCase()] ?? null
}
}
// ─── MAIN COMPONENT ───────────────────────────────────────────────────────────
export function LicenseLookupPanel({ onLicenseResolved }: LicenseLookupPanelProps) {
const [licenseKey, setLicenseKey] = useState('')
const [result, setResult] = useState<LicenseInfo | null>(null)
const [notFound, setNotFound] = useState(false)
const [isPending, startTransition] = useTransition()
const [expanded, setExpanded] = useState(true)
const [modulesOpen, setModulesOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = () => {
if (!licenseKey.trim()) return
setResult(null)
setNotFound(false)
startTransition(async () => {
const info = await lookupLicense(licenseKey)
if (info) {
setResult(info)
onLicenseResolved?.(info)
} else {
setNotFound(true)
}
})
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSearch()
}
const handleClear = () => {
setLicenseKey('')
setResult(null)
setNotFound(false)
inputRef.current?.focus()
}
const sc = result ? statusConfig(result.status) : null
return (
<div className="rounded-2xl border border-white/10 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl">
{/* ── Panel Header (toggle) ─────────────────────────────────────────── */}
<button
id="license-lookup-toggle"
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-violet-500/20 flex items-center justify-center">
<KeyRound className="w-4 h-4 text-violet-400" />
</div>
<div className="text-left">
<p className="text-sm font-semibold text-white">Alte Lizenznummer eingeben</p>
<p className="text-[11px] text-slate-400">Upgrade / Migration</p>
</div>
</div>
{expanded
? <ChevronUp className="w-4 h-4 text-slate-400" />
: <ChevronDown className="w-4 h-4 text-slate-400" />}
</button>
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-5 pb-5 space-y-4 border-t border-white/5">
{/* ── Input + Search Button ──────────────────────────────── */}
<div className="pt-4">
<p className="text-xs text-slate-400 mb-2 leading-relaxed">
Geben Sie Ihre bisherige CAS-Lizenznummer ein,
um Lizenzinformationen automatisch abzurufen.
</p>
<div className="flex items-center gap-2">
{/* Input */}
<div className="relative flex-1">
<Fingerprint className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-violet-400 pointer-events-none" />
<Input
ref={inputRef}
id="license-lookup-input"
value={licenseKey}
onChange={e => {
setLicenseKey(e.target.value)
setResult(null)
setNotFound(false)
}}
onKeyDown={handleKeyDown}
placeholder="z. B. CAS-2023-PRO-00123"
className="pl-9 pr-8 bg-white/5 border-white/10 text-white placeholder:text-slate-500
focus:border-violet-500/60 focus:ring-violet-500/20 rounded-xl text-sm h-10"
/>
{licenseKey && (
<button
onClick={handleClear}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors"
aria-label="Eingabe löschen"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{/* Search Button */}
<button
id="license-lookup-search-btn"
onClick={handleSearch}
disabled={isPending || !licenseKey.trim()}
className="flex items-center gap-1.5 px-3.5 h-10 rounded-xl bg-violet-600 hover:bg-violet-500
disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium
transition-all shadow-lg shadow-violet-500/20 shrink-0"
>
{isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Search className="w-4 h-4" />}
Prüfen
</button>
</div>
{/* Demo quick-fill hints */}
<p className="text-[10px] text-slate-500 mt-1.5">
Demo:&nbsp;
<span
className="font-mono text-violet-400/80 cursor-pointer hover:text-violet-400 transition-colors"
onClick={() => setLicenseKey('CAS-2023-PRO-00123')}
>
CAS-2023-PRO-00123
</span>
{' · '}
<span
className="font-mono text-amber-400/80 cursor-pointer hover:text-amber-400 transition-colors"
onClick={() => setLicenseKey('CAS-2020-STD-00456')}
>
CAS-2020-STD-00456
</span>
</p>
</div>
{/* ── Animated Result Area ──────────────────────────────── */}
<AnimatePresence mode="wait">
{/* Loading skeleton */}
{isPending && (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-2"
>
{[1, 2, 3].map(i => (
<div key={i} className="h-7 rounded-lg bg-white/5 animate-pulse" />
))}
<p className="text-center text-[11px] text-slate-500 animate-pulse">
Lizenzserver wird abgefragt
</p>
</motion.div>
)}
{/* Not Found */}
{notFound && !isPending && (
<motion.div
key="notfound"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-start gap-2.5 p-3 rounded-xl bg-red-500/10 border border-red-500/30"
>
<XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-red-300">Lizenz nicht gefunden</p>
<p className="text-xs text-red-400/70 mt-0.5">
Die eingegebene Nummer wurde im Lizenzsystem nicht gefunden.
Bitte prüfen Sie die Eingabe.
</p>
</div>
</motion.div>
)}
{/* Result Card */}
{result && !isPending && sc && (
<motion.div
key="result"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={`rounded-xl border ${sc.ring} bg-slate-900/80 shadow-lg ${sc.glow} overflow-hidden`}
>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/5">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-slate-300" />
<span className="text-xs font-mono text-slate-300 truncate max-w-[140px]">
{result.licenseKey}
</span>
</div>
<Badge className={`text-[10px] px-2 py-0.5 flex items-center gap-1 border ${sc.cls}`}>
{sc.icon}
{sc.label}
</Badge>
</div>
{/* Info Rows */}
<div className="px-4 py-3 space-y-2.5">
{/* Product */}
<div className="flex items-start gap-2">
<Package className="w-3.5 h-3.5 text-violet-400 mt-0.5 shrink-0" />
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Produkt</p>
<p className="text-xs font-semibold text-white leading-tight">{result.product}</p>
<p className="text-[10px] text-slate-400">
{result.edition} · v{result.version} · {result.seats} Arbeitsplätze
</p>
</div>
</div>
{/* Customer */}
<div className="flex items-start gap-2">
<Building2 className="w-3.5 h-3.5 text-blue-400 mt-0.5 shrink-0" />
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-wide">Lizenznehmer</p>
<p className="text-xs font-semibold text-white leading-tight">{result.customer}</p>
<p className="text-[10px] text-slate-400 flex items-center gap-1">
<User className="w-2.5 h-2.5" />
{result.contact}
</p>
</div>
</div>
{/* Date Grid */}
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="rounded-lg bg-white/5 px-2.5 py-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<CalendarDays className="w-2.5 h-2.5" /> Ausgestellt
</p>
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.issuedAt)}</p>
</div>
<div className="rounded-lg bg-white/5 px-2.5 py-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<CalendarDays className="w-2.5 h-2.5" /> Läuft ab
</p>
<p className={`text-xs font-medium mt-0.5 ${result.status === 'expired' ? 'text-amber-400' : 'text-white'}`}>
{fmt(result.expiresAt)}
</p>
</div>
<div className="rounded-lg bg-white/5 px-2.5 py-2 col-span-2">
<p className="text-[9px] text-slate-500 uppercase tracking-wide flex items-center gap-1">
<Hash className="w-2.5 h-2.5" /> Wartung bis
</p>
<p className="text-xs text-white font-medium mt-0.5">{fmt(result.maintenanceUntil)}</p>
</div>
</div>
{/* Modules (collapsible) */}
{result.modules.length > 0 && (
<div>
<button
onClick={() => setModulesOpen(v => !v)}
className="w-full flex items-center justify-between text-[10px] text-slate-400
hover:text-white transition-colors pt-1"
>
<span className="uppercase tracking-wide">
Lizenzierte Module ({result.modules.length})
</span>
{modulesOpen
? <ChevronUp className="w-3 h-3" />
: <ChevronDown className="w-3 h-3" />}
</button>
<AnimatePresence>
{modulesOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="flex flex-wrap gap-1 pt-2">
{result.modules.map(m => (
<span
key={m}
className="text-[10px] px-2 py-0.5 rounded-full bg-violet-500/15
border border-violet-500/30 text-violet-300"
>
{m}
</span>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Bottom hint */}
{result.status === 'active' && (
<div className="px-4 pb-3">
<p className="text-[10px] text-emerald-400/70 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Lizenz erkannt Daten können für die Bestellung übernommen werden.
</p>
</div>
)}
{result.status === 'expired' && (
<div className="px-4 pb-3">
<p className="text-[10px] text-amber-400/70 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Lizenz abgelaufen Upgrade empfohlen.
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { Check } from 'lucide-react'
interface ProgressStepperProps {
step: number
basketItemsCount: number
}
export function ProgressStepper({ step, basketItemsCount }: ProgressStepperProps) {
return (
<div className="max-w-md mx-auto mb-12">
<div className="flex justify-between relative">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
{[1, 2, 3, 4].map(s => (
<div key={s} className="relative z-10 flex flex-col items-center gap-2">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
step >= s
? 'bg-blue-600 text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
: 'bg-slate-800 text-slate-400'
}`}
>
{step > s ? <Check className="w-5 h-5" /> : s}
</div>
<span className={`text-xs font-semibold ${step >= s ? 'text-blue-400' : 'text-slate-500'} flex items-center gap-1`}>
{s === 1 ? 'Kunde' : s === 2 ? 'Modell' : s === 3 ? 'Software' : 'Abschluss'}
{s === 3 && basketItemsCount > 0 && (
<span className="bg-blue-600 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold px-1.5 leading-none">
{basketItemsCount}
</span>
)}
</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { CreditCard, Calendar, Check, ChevronLeft, ChevronRight } from 'lucide-react'
interface StepBillingProps {
selectedBillingInterval: 'one_time' | 'monthly'
handleBillingIntervalChange: (val: 'one_time' | 'monthly') => void
customerMode: 'select' | 'create'
selectedEndCustomerId: string | null
lastLicenseDate: string
setLastLicenseDate: (date: string) => void
prevStep: () => void
nextStep: () => void
}
export function StepBilling({
selectedBillingInterval,
handleBillingIntervalChange,
customerMode,
selectedEndCustomerId,
lastLicenseDate,
setLastLicenseDate,
prevStep,
nextStep,
}: StepBillingProps) {
return (
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<CreditCard className="w-6 h-6 text-primary" />
Abrechnungsmodell wählen
</CardTitle>
<CardDescription className="text-slate-300">
Möchten Sie Lizenzen dauerhaft kaufen oder monatlich mieten?
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6 py-4">
{/* Option 1: Kauf */}
<div
onClick={() => handleBillingIntervalChange('one_time')}
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
selectedBillingInterval === 'one_time'
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div>
<div className="flex items-center gap-3 mb-3">
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'one_time' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
<CreditCard className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-white">Einmaliger Kauf</h3>
</div>
<p className="text-slate-400 text-sm">
Einmalige Anschaffungskosten für die Softwarelizenz. Keine monatlichen Mietgebühren.
</p>
</div>
{selectedBillingInterval === 'one_time' && (
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
<Check className="w-4 h-4" />
</div>
)}
</div>
{/* Option 2: Abo */}
<div
onClick={() => handleBillingIntervalChange('monthly')}
className={`relative p-6 rounded-2xl border-2 cursor-pointer transition-all duration-300 flex flex-col justify-between h-48 hover:shadow-[0_0_20px_rgba(255,255,255,0.05)] ${
selectedBillingInterval === 'monthly'
? 'border-primary bg-primary/10 shadow-[0_0_25px_rgba(59,130,246,0.2)]'
: 'border-white/5 bg-white/5 hover:border-white/20'
}`}
>
<div>
<div className="flex items-center gap-3 mb-3">
<div className={`p-3 rounded-xl ${selectedBillingInterval === 'monthly' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-slate-400'}`}>
<Calendar className="w-6 h-6" />
</div>
<h3 className="text-lg font-bold text-white">Monatliches Abo (Miete)</h3>
</div>
<p className="text-slate-400 text-sm">
Laufende monatliche Gebühren. Inklusive aller Updates und flexibler Laufzeit.
</p>
</div>
{selectedBillingInterval === 'monthly' && (
<div className="absolute top-4 right-4 w-6 h-6 rounded-full bg-primary flex items-center justify-center text-white">
<Check className="w-4 h-4" />
</div>
)}
</div>
</div>
{selectedBillingInterval === 'one_time' && customerMode === 'select' && selectedEndCustomerId && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3 mt-6 max-w-md animate-in fade-in slide-in-from-top-2 duration-300">
<Label htmlFor="last-license-date" className="text-white font-medium flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
Datum der letzten CAS-Lizenz (falls vorhanden)
</Label>
<Input
id="last-license-date"
type="date"
value={lastLicenseDate}
onChange={e => setLastLicenseDate(e.target.value)}
className="bg-[#0b1329] border-white/10 text-white focus:border-primary"
/>
<p className="text-xs text-slate-400">
Falls dieser Kunde bereits Lizenzen besitzt, tragen Sie das Datum der letzten Lizenzierung ein. Damit werden die korrekten Update-Gebühren ermittelt.
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
<Button variant="ghost" className="text-white" onClick={prevStep}>
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
</Button>
<Button onClick={nextStep}>
Weiter zur Software <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { User, Building2, UserPlus, Search, Check, Loader2, ChevronRight } from 'lucide-react'
import { EndCustomer, Profile } from '@/lib/types'
interface StepCustomerProps {
isAdmin: boolean
companies: any[]
selectedCompanyId: string | null
setSelectedCompanyId: (id: string | null) => void
customerMode: 'select' | 'create'
setCustomerMode: (mode: 'select' | 'create') => void
endCustomers: EndCustomer[]
searchTerm: string
setSearchTerm: (term: string) => void
filteredEndCustomers: EndCustomer[]
selectedEndCustomerId: string | null
setSelectedEndCustomerId: (id: string | null) => void
newCustomerForm: any
setNewCustomerForm: React.Dispatch<React.SetStateAction<any>>
handleCreateCustomer: () => Promise<void>
isCreatingCustomer: boolean
nextStep: () => void
}
export function StepCustomer({
isAdmin,
companies,
selectedCompanyId,
setSelectedCompanyId,
customerMode,
setCustomerMode,
endCustomers,
searchTerm,
setSearchTerm,
filteredEndCustomers,
selectedEndCustomerId,
setSelectedEndCustomerId,
newCustomerForm,
setNewCustomerForm,
handleCreateCustomer,
isCreatingCustomer,
nextStep,
}: StepCustomerProps) {
return (
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<User className="w-6 h-6 text-primary" />
Für wen bestellen Sie?
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie einen Endkunden aus oder legen Sie einen neuen an.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Admin Company Selector */}
{isAdmin && (
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-3">
<Label htmlFor="order-company" className="text-white font-medium flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary" />
Zugeordnetes Unternehmen (Admin-Option)
</Label>
<select
id="order-company"
value={selectedCompanyId || ''}
onChange={(e) => setSelectedCompanyId(e.target.value || null)}
className="flex h-9 w-full rounded-md border border-white/10 bg-slate-900 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-white"
>
<option value="">Keine Firma zugewiesen</option>
{companies.map((c: any) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
)}
{/* Modus-Toggle */}
<div className="flex gap-2">
<Button
variant={customerMode === 'select' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('select')}
className={`gap-2 ${customerMode === 'select' ? '' : 'text-white'}`}
>
<Building2 className="w-4 h-4" /> Bestandskunde wählen
</Button>
<Button
variant={customerMode === 'create' ? 'default' : 'ghost'}
size="sm"
onClick={() => setCustomerMode('create')}
className={`gap-2 ${customerMode === 'create' ? '' : 'text-white'}`}
>
<UserPlus className="w-4 h-4" /> Neuen Kunden anlegen
</Button>
</div>
{/* Modus A: Bestandskunde wählen */}
{customerMode === 'select' && (
<div className="space-y-4">
{endCustomers.filter(c => !c.is_anonymized).length === 0 ? (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center space-y-3">
<Building2 className="w-10 h-10 text-slate-600 mx-auto" />
<p className="text-slate-400">Noch keine Endkunden angelegt.</p>
<Button variant="outline" size="sm" onClick={() => setCustomerMode('create')} className="border-white/10 text-white">
<UserPlus className="w-4 h-4 mr-2" /> Ersten Kunden anlegen
</Button>
</div>
) : (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Kunden suchen nach Name, Ort, PLZ..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
{filteredEndCustomers.length === 0 ? (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
<p className="text-slate-400">Keine passenden Kunden gefunden.</p>
</div>
) : (
<div className="space-y-2 max-h-72 overflow-y-auto pr-1">
{filteredEndCustomers.map(customer => (
<label
key={customer.id}
className={`flex items-start p-4 rounded-xl border-2 cursor-pointer transition-all ${selectedEndCustomerId === customer.id
? 'border-primary bg-primary/5'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<input
type="radio"
name="end_customer"
value={customer.id}
checked={selectedEndCustomerId === customer.id}
onChange={() => setSelectedEndCustomerId(customer.id)}
className="sr-only"
/>
<div className="flex-1">
<p className="font-semibold text-white">{customer.company_name}</p>
<p className="text-sm text-slate-400">
{[customer.first_name, customer.last_name].filter(Boolean).join(' ')}
{customer.first_name || customer.last_name ? ' · ' : ''}
{[customer.street, customer.zip, customer.city].filter(Boolean).join(', ')}
</p>
</div>
{selectedEndCustomerId === customer.id && (
<Check className="w-5 h-5 text-primary mt-0.5 shrink-0" />
)}
</label>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Modus B: Neuen Kunden anlegen */}
{customerMode === 'create' && (
<div className="grid md:grid-cols-2 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
{([
{ label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' },
{ label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' },
{ label: 'Vorname', key: 'first_name', span: false, placeholder: '' },
{ label: 'Nachname', key: 'last_name', span: false, placeholder: '' },
{ label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' },
{ label: 'PLZ', key: 'zip', span: false, placeholder: '' },
{ label: 'Ort', key: 'city', span: false, placeholder: '' },
{ label: 'Kontoinhaber', key: 'bank_owner', span: false, placeholder: 'Max Mustermann' },
{ label: 'IBAN', key: 'bank_iban', span: false, placeholder: 'DE89370400440532013000' },
{ label: 'BIC', key: 'bank_bic', span: false, placeholder: 'SOLADE21XXX' },
{ label: 'Bankname', key: 'bank_name', span: false, placeholder: 'Musterbank' },
] as const).map(({ label, key, span, placeholder }) => (
<div key={key} className={`space-y-1.5 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-slate-300 text-sm">{label}</Label>
<Input
value={newCustomerForm[key] || ''}
onChange={e => setNewCustomerForm((prev: any) => ({ ...prev, [key]: e.target.value }))}
placeholder={placeholder}
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
))}
<div className="md:col-span-2 flex gap-2">
<Button
onClick={handleCreateCustomer}
disabled={isCreatingCustomer || !newCustomerForm.company_name?.trim()}
className="gap-2"
>
{isCreatingCustomer ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
Kunden speichern & auswählen
</Button>
<Button variant="ghost" className="text-white" onClick={() => setCustomerMode('select')}>
Abbrechen
</Button>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end border-t border-white/10 pt-6">
<Button
onClick={nextStep}
disabled={
customerMode === 'create' ||
(customerMode === 'select' && endCustomers.filter(c => !c.is_anonymized).length > 0 && !selectedEndCustomerId)
}
>
Weiter zum Abrechnungsmodell <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,295 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShoppingCart, Check, AlertCircle } from 'lucide-react'
import * as Icons from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
interface StepSoftwareProps {
visibleCategories: Category[]
products: Product[]
selections: Record<string, CategorySelection>
selectProduct: (catId: string, prodId: string) => void
isProductDisabled: (prod: Product, catId: string) => boolean
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
toggleModule: (catId: string, modId: string) => void
moduleQuantities: Record<string, number>
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
selectedBillingInterval: 'one_time' | 'monthly'
billingLabel: (interval: string) => string
billingBadgeClass: (interval: string) => string
}
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSoftware({
visibleCategories,
products,
selections,
selectProduct,
isProductDisabled,
isModuleDisabled,
toggleModule,
moduleQuantities,
setModuleQuantities,
selectedBillingInterval,
billingLabel,
billingBadgeClass,
}: StepSoftwareProps) {
return (
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2 text-white">
<ShoppingCart className="w-6 h-6 text-blue-400" />
Software wählen
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{visibleCategories.map((cat, idx) => {
const catProducts = products.filter(p => {
if (p.category_id !== cat.id) return false
return selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false
: p.show_in_abo !== false
})
const sel = selections[cat.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<div key={cat.id}>
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
{/* Category header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
{cat.description && (
<p className="text-slate-400 text-xs">{cat.description}</p>
)}
</div>
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : cat.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-80">
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
Optional
</Badge>
)}
</div>
{catProducts.length === 0 ? (
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : cat.allow_multiselect ? (
<div className="grid gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
const disabled = isProductDisabled(product, cat.id)
return (
<div key={product.id} className="relative">
<Label
onClick={() => !disabled && selectProduct(cat.id, product.id)}
className={`flex flex-col items-start p-4 rounded-xl border-2 transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-50 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/5 cursor-pointer'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-3">
<Checkbox
checked={isChecked}
disabled={disabled}
onCheckedChange={() => { }}
className="border-white/20 data-[state=checked]:bg-primary"
/>
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1 pl-7">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1 pl-7">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(cat.id, id)}
className="grid gap-3"
>
{catProducts.map(product => {
const disabled = isProductDisabled(product, cat.id)
return (
<div key={product.id} className="relative">
<RadioGroupItem
value={product.id}
id={`${cat.id}-${product.id}`}
disabled={disabled}
className="peer sr-only"
/>
<Label
htmlFor={disabled ? undefined : `${cat.id}-${product.id}`}
className={`flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 transition-all ${disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-2">
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
)}
{/* Modules */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
{selectedProduct.modules.map(module => {
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = sel?.moduleIds.includes(module.id) ?? false
return (
<div
key={module.id}
className={`flex flex-col p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
>
<div className="flex items-start space-x-3">
<Checkbox
id={`mod-${cat.id}-${module.id}`}
checked={checked}
onCheckedChange={() => toggleModule(cat.id, module.id)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`mod-${cat.id}-${module.id}`}
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary font-bold">
+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400">{module.description}</p>
)}
{disabled && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
{/* Scalable Quantity */}
{checked && module.has_quantity && (
<div className="flex items-center gap-3 mt-3 pl-8 pt-2 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400">Menge:</Label>
<Input
id={`qty-${module.id}`}
type="number"
min={1}
max={999}
value={moduleQuantities[module.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
}}
className="w-20 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg"
/>
<span className="text-xs text-slate-500">
Gesamt: {new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price * (moduleQuantities[module.id] || 1))}
</span>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,275 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { ShieldCheck, Building2, Calendar, Loader2 } from 'lucide-react'
import * as Icons from 'lucide-react'
import { Category, Product, EndCustomer, Profile } from '@/lib/types'
interface StepSummaryProps {
finalItemsToShow: any[]
visibleCategories: Category[]
products: Product[]
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
selectedEndCustomer: EndCustomer | null
customerData: Partial<Profile>
lastLicenseDate: string
handleSubmit: () => Promise<void>
isSubmitting: boolean
initialOrder: any
prevStep: () => void
}
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSummary({
finalItemsToShow,
visibleCategories,
products,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
selectedEndCustomer,
customerData,
lastLicenseDate,
handleSubmit,
isSubmitting,
initialOrder,
prevStep,
}: StepSummaryProps) {
return (
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
<CardHeader>
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-3xl text-white">Anfrage prüfen</CardTitle>
<CardDescription className="text-slate-300">
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 text-left">
<div className="p-4 rounded-lg bg-white/5 space-y-6">
{finalItemsToShow.map((item, itemIdx) => (
<div key={itemIdx} className="space-y-3 border-b border-white/10 pb-4 last:border-0 last:pb-0">
<div className="flex justify-between items-center bg-white/5 p-2 rounded">
<span className="text-white font-bold text-sm">Kasse: {item.deviceName}</span>
<span className="text-xs text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
</div>
{visibleCategories.map(cat => {
const sel = item.selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach((pId: string) => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return null
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1 pl-2">
<div className="text-slate-400 font-semibold text-xs flex items-center gap-1">
<CategoryIcon icon={cat.icon} className="w-3.5 h-3.5 text-primary" />
<span>{cat.name}</span>
</div>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
const catTotal =
Number(actualPrice) +
(sel?.moduleIds?.reduce((acc: number, mId: string) => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = item.moduleQuantities[mId] || 1
return acc + (Number(mod?.price ?? 0) * qty)
}, 0) || 0)
return (
<div key={prod.id} className="pl-3">
<div className="flex justify-between font-semibold text-sm text-white">
<span>
{prod.name} {isFree && <span className="text-[10px] text-green-400 font-semibold">(Frei)</span>}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
</span>
</div>
{sel?.moduleIds && sel.moduleIds.length > 0 && (
<p className="text-xs text-slate-400 mt-0.5">
Module: {sel.moduleIds
.map((id: string) => {
const mod = prod.modules?.find(m => m.id === id)
const qty = item.moduleQuantities[id] || 1
return mod ? `${mod.name}${mod.has_quantity ? ` (x${qty})` : ''}` : ''
})
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
)
})}
</div>
)
})}
</div>
))}
<Separator className="bg-white/10" />
<div className="text-sm text-slate-300 space-y-1">
{selectedEndCustomer ? (
<>
<p className="font-semibold text-white flex items-center gap-2">
<Building2 className="w-3.5 h-3.5 text-primary" />
{selectedEndCustomer.company_name}
</p>
<p>{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}</p>
<p>{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}</p>
<p className="text-xs text-slate-500 mt-1">Endkunde Ihres Partners</p>
{lastLicenseDate && (
<p className="text-xs text-slate-400 flex items-center gap-1 mt-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-primary" />
Letzte Lizenz vom: {new Date(lastLicenseDate).toLocaleDateString('de-DE')}
</p>
)}
</>
) : (
<>
<p className="font-semibold text-white">{customerData.company_name}</p>
<p>{customerData.first_name} {customerData.last_name}</p>
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
</>
)}
</div>
</div>
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-4 border-t border-white/10 pt-4">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmaliger Betrag:</p>
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlicher Betrag:</p>
<div className="flex justify-between text-sm text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-bold text-white">
<span>Brutto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1 border-t border-white/10 pt-4">
<div className="flex justify-between text-sm text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
{updatePriceModifier.label && (
<div className="text-sm text-green-400 font-semibold bg-green-500/10 p-2 rounded border border-green-500/20 my-1">
{updatePriceModifier.label}
</div>
)}
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1 border-t border-white/10 pt-4">
<div className="flex justify-between text-sm text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-sm text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-xl font-bold text-blue-500 dark:text-blue-400 pt-1 border-t border-white/5">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
)}
<p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die{' '}
<a href="/datenschutz" target="_blank" rel="noopener noreferrer" className="underline hover:text-slate-400">
Datenschutzerklärung
</a>.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
initialOrder ? (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird aktualisiert...</>
) : (
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Anfrage wird versendet...</>
)
) : (
initialOrder ? 'Anfrage aktualisieren' : 'Anfrage versenden'
)}
</Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
Noch etwas ändern
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,297 @@
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Pencil, Trash2, UserPlus, ChevronRight, AlertCircle, Check } from 'lucide-react'
import { Category, Product, CategorySelection } from '@/lib/types'
interface SummarySidebarProps {
visibleCategories: Category[]
selections: Record<string, CategorySelection>
products: Product[]
moduleQuantities: Record<string, number>
billingLabel: (interval: string) => string
oneTimeTotal: number
monthlyTotal: number
oneTimeNet: number
oneTimeTax: number
oneTimeGross: number
monthlyNet: number
monthlyTax: number
monthlyGross: number
updatePriceModifier: { label?: string }
allCategoriesFilled: boolean
productValidationErrors: string[]
basketItems: any[]
editingIdx: number | null
editBasketItem: (idx: number) => void
deleteBasketItem: (idx: number) => void
deviceName: string
setDeviceName: (name: string) => void
addToBasket: () => void
isNextStepDisabled: boolean
hasActiveSelection: boolean
nextStep: () => void
prevStep: () => void
}
export function SummarySidebar({
visibleCategories,
selections,
products,
moduleQuantities,
billingLabel,
oneTimeTotal,
monthlyTotal,
oneTimeNet,
oneTimeTax,
oneTimeGross,
monthlyNet,
monthlyTax,
monthlyGross,
updatePriceModifier,
allCategoriesFilled,
productValidationErrors,
basketItems,
editingIdx,
editBasketItem,
deleteBasketItem,
deviceName,
setDeviceName,
addToBasket,
isNextStepDisabled,
hasActiveSelection,
nextStep,
prevStep,
}: SummarySidebarProps) {
return (
<Card className="glass-dark border-primary/20 backdrop-blur-lg bg-slate-950/75 flex flex-col max-h-[80vh]">
<CardHeader className="shrink-0">
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-y-auto subpixel-antialiased scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{visibleCategories.map(cat => {
const sel = selections[cat.id]
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel?.productIds) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel?.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
if (selectedProds.length === 0) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className="w-3 h-3 inline-block" />
)}
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
return (
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
{sortedProds.map((prod, idx) => {
const isFree = idx < freeLimit
const actualPrice = isFree ? 0 : prod.base_price
return (
<div key={prod.id} className="space-y-0.5 pl-2">
<div className="flex justify-between text-sm">
<span className="text-white">{prod.name} {isFree && <span className="text-[10px] text-green-400">(Frei)</span>}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(actualPrice)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
const qty = moduleQuantities[mId] || 1
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-2">
<span className="text-slate-300">+ {mod.name} {mod.has_quantity ? `(x${qty})` : ''}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price * qty)}
</span>
</div>
) : null
})}
</div>
)
})}
</div>
)
})}
<Separator className="bg-white/10" />
{oneTimeTotal > 0 && monthlyTotal > 0 ? (
<div className="space-y-3">
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Einmalig:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
<div className="space-y-1 pt-2 border-t border-white/10">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Monatlich:</p>
<div className="flex justify-between text-xs text-slate-400">
<span>Netto:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-sm font-bold text-white">
<span>Gesamt (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
</div>
) : oneTimeTotal > 0 ? (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</span>
</div>
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}</span>
</div>
</div>
) : (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>Netto-Gesamtbetrag:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</span>
</div>
<div className="flex justify-between text-xs text-slate-400">
<span>zzgl. 19% MwSt.:</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</span>
</div>
<div className="flex justify-between text-base font-bold text-blue-500 dark:text-blue-400">
<span>Gesamtbetrag (brutto):</span>
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.</span>
</div>
</div>
)}
{updatePriceModifier.label && (
<div className="text-xs text-green-400 bg-green-500/10 p-2.5 rounded-lg border border-green-500/20 space-y-1">
<p className="font-semibold flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
Update-Rabatt aktiv
</p>
<p>{updatePriceModifier.label}</p>
</div>
)}
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p>
)}
{productValidationErrors.map((err, errIdx) => (
<p key={errIdx} className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-destructive" />
{err}
</p>
))}
{basketItems.length > 0 && (
<div className="pt-4 border-t border-white/10 space-y-2">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Warenkorb ({basketItems.length}):</p>
<div className="space-y-1.5">
{basketItems.map((item, idx) => (
<div key={idx} className={`flex justify-between items-center text-xs bg-white/5 p-2 rounded border transition-all duration-300 ${idx === editingIdx ? 'border-blue-500/50 shadow-[0_0_10px_rgba(59,130,246,0.3)]' : 'border-white/10'}`}>
<div className="flex flex-col">
<span className="text-white font-medium">{item.deviceName}</span>
<span className="text-[10px] text-slate-400 capitalize">{item.billingInterval === 'one_time' ? 'Kauf' : 'Abo'}</span>
</div>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon"
className="w-7 h-7 hover:bg-white/10 text-slate-400 hover:text-white"
onClick={() => editBasketItem(idx)}
title="Kasse bearbeiten"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="w-7 h-7 hover:bg-white/10 text-destructive hover:text-red-400"
onClick={() => deleteBasketItem(idx)}
title="Kasse löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-3 shrink-0">
<div className="w-full space-y-2">
<Label htmlFor="device-name" className="text-xs text-slate-400">Kassenname (optional)</Label>
<Input
id="device-name"
placeholder="z.B. Hauptkasse, Theke"
value={deviceName}
onChange={e => setDeviceName(e.target.value)}
className="bg-white/5 border-white/10 text-white text-xs h-9 rounded-lg"
/>
</div>
<Button
type="button"
variant="outline"
className="w-full border-primary/30 text-white hover:bg-primary/10 gap-2 h-10 text-sm"
onClick={addToBasket}
disabled={isNextStepDisabled}
>
<UserPlus className="w-4 h-4" /> {editingIdx !== null ? '💾 Änderungen an Kasse speichern' : (hasActiveSelection ? 'Kasse speichern' : 'Kasse anlegen')}
</Button>
<Separator className="bg-white/10 my-1" />
<Button
className="w-full h-12 text-lg"
onClick={nextStep}
disabled={basketItems.length === 0 && isNextStepDisabled}
>
Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button>
<Button variant="ghost" className="w-full text-white" onClick={prevStep}>
Zurück zum Abrechnungsmodell
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ToastProps {
toast: { message: string; type: 'error' | 'success' } | null
onClose: () => void
}
export function ToastNotification({ toast, onClose }: ToastProps) {
return (
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className={`fixed bottom-5 right-5 z-50 flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg backdrop-blur-md transition-all duration-300 max-w-md ${
toast.type === 'error'
? 'bg-red-500/20 border-red-500/40 text-red-200'
: 'bg-green-500/20 border-green-500/40 text-green-200'
}`}
>
<AlertCircle className={`w-5 h-5 shrink-0 ${toast.type === 'error' ? 'text-red-400' : 'text-green-400'}`} />
<p className="text-sm font-medium">{toast.message}</p>
<Button
variant="ghost"
size="icon"
className="w-5 h-5 ml-auto text-slate-400 hover:text-white"
onClick={onClose}
>
</Button>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -45,7 +45,8 @@ export async function signIn(email: string, password: string) {
const userId = data.user?.id
if (userId) {
let { data: userData, error: userError } = await supabase
const adminClient = createAdminClient()
let { data: userData, error: userError } = await adminClient
.from('users')
.select('role')
.eq('id', userId)

View File

@@ -1,6 +1,7 @@
'use server'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath, unstable_noStore as noStore } from 'next/cache'
import { getLicServerConfig, getLicServerConfigSystem } from './licserver-config'
export async function getCompanies() {
noStore()
@@ -219,3 +220,135 @@ export async function adminDeleteEndCustomer(id: string) {
return true
}
export async function syncCompaniesFromLicServer() {
const admin = createAdminClient()
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1000`, {
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
}
const result = await res.json()
const partners = result.items || []
if (partners.length === 0) {
return { success: true, count: 0 }
}
const upsertRows = partners.map((p: any) => ({
id: p.id,
name: p.name || 'Unbenannt',
email: p.email || null,
}))
const { error } = await admin
.from('companies')
.upsert(upsertRows, { onConflict: 'id' })
if (error) throw error
revalidatePath('/admin/companies')
revalidatePath('/admin/users')
return { success: true, count: partners.length }
}
export async function syncCustomersFromLicServer() {
const admin = createAdminClient()
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
// 1. Get all local companies
const { data: companies, error: compErr } = await admin
.from('companies')
.select('id')
if (compErr) throw compErr
if (!companies || companies.length === 0) {
return { success: true, count: 0 }
}
let totalSynced = 0
const allUpsertRows: any[] = []
// 2. Fetch customers for each company
for (const company of companies) {
try {
// Workaround for LicServer crash on null/empty search parameter:
// We query using common vowels/umlauts in parallel and de-duplicate locally.
const searchChars = ['a', 'e', 'i', 'o', 'u', 'ä', 'ö', 'ü', 'y']
const customerMap = new Map<string, any>()
await Promise.all(
searchChars.map(async char => {
try {
const res = await fetch(`${cfg.base_url}/api-v1/partners/${company.id}/customers?pageSize=1000&search=${encodeURIComponent(char)}`, {
headers: { 'X-Api-Key': cfg.api_key!, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (res.ok) {
const result = await res.json()
const items = result.items || []
for (const c of items) {
customerMap.set(c.id, c)
}
}
} catch (e: any) {
console.error(`[sync-customers] Error fetching with search=${char} for partner ${company.id}:`, e.message || e)
}
})
)
const customers = Array.from(customerMap.values())
for (const c of customers) {
const streetCombined = [c.street, c.houseNumber, c.houseNumberAddon]
.filter(Boolean)
.map(s => s.trim())
.join(' ')
allUpsertRows.push({
id: c.id,
partner_id: company.id,
company_name: c.name || 'Unbenannt',
street: streetCombined || null,
zip: c.zipCode || null,
city: c.city || null,
email: c.email || null,
is_anonymized: false,
})
}
} catch (e: any) {
console.error(`[sync-customers] Error fetching customers for partner ${company.id}:`, e.message || e)
}
}
if (allUpsertRows.length > 0) {
const { error: upsertErr } = await admin
.from('end_customers')
.upsert(allUpsertRows, { onConflict: 'id' })
if (upsertErr) throw upsertErr
totalSynced = allUpsertRows.length
}
revalidatePath('/my-customers')
revalidatePath('/order')
revalidatePath('/admin/companies')
return { success: true, count: totalSynced }
}

View File

@@ -0,0 +1,150 @@
interface EmailDetails {
orderNumber: string
formattedDate: string
customerCompanyName: string
totalDetailsText: string
totalDetailsHtml: string
itemsDetailsText?: string
itemsDetailsHtml?: string
}
export function getOrderEmailTemplate(
details: EmailDetails,
siteUrl: string,
isUpdate: boolean = false
) {
const title = isUpdate ? 'Anfrage geändert' : 'Anfrage erhalten'
const intro = isUpdate
? `Deine Anfrage ${details.orderNumber} wurde aktualisiert.`
: `Deine Anfrage ${details.orderNumber} ist bei uns eingegangen und wird bearbeitet.`
const text = `Hallo,\n\n${isUpdate ? 'Deine Anfrage wurde aktualisiert.' : 'Deine Anfrage ist bei uns eingegangen.'}\n\nDetails:\n- Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Kunde: ${details.customerCompanyName}\n${details.itemsDetailsText || ''}\n${details.totalDetailsText}\n\nDeine Anfragebestätigung findest du im Anhang als PDF.\n\nPDF-Link: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nDein CASPOS Team`
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">${title}</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">${intro}</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
<td style="padding: 4px 0;">${details.orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${details.formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Kunde:</td>
<td style="padding: 4px 0;">${details.customerCompanyName}</td>
</tr>
${details.itemsDetailsHtml || ''}
${details.totalDetailsHtml}
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Die Bestätigung liegt als PDF im Anhang.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">
<a href="${siteUrl}/api/orders/${details.orderNumber}/download" style="color: #3b82f6; text-decoration: underline; font-weight: 500;">Anfragebestätigung PDF herunterladen</a>
</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
`
return { text, html }
}
export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) {
const text = `Hallo,\n\nder Status deiner Anfrage ${orderNumber} hat sich geändert.\n\nStatus: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nDein CASPOS Team`
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Statusänderung</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status deiner Anfrage <strong>${orderNumber}</strong> wurde aktualisiert.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Nummer:</td>
<td style="padding: 4px 0;">${orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Vorher:</td>
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Aktuell:</td>
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
</tr>
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Dein CASPOS Team</p>
</div>
`
return { text, html }
}
export function buildEmailItemsSection(items: any[]) {
const groupedItems: { [key: string]: any[] } = {}
items.forEach((item: any) => {
const devName = item.device_name || 'Kasse 1'
if (!groupedItems[devName]) {
groupedItems[devName] = []
}
groupedItems[devName].push(item)
})
let itemsDetailsText = '\nKassen-Konfigurationen:'
let itemsDetailsHtml = `
<tr>
<td colspan="2" style="padding: 12px 0 6px 0; border-top: 1px solid #e2e8f0; font-weight: bold; color: #0f172a;">Kassen-Konfigurationen:</td>
</tr>
`
Object.entries(groupedItems).forEach(([deviceName, devItems]) => {
itemsDetailsText += `\n- Kasse: ${deviceName}`
itemsDetailsHtml += `
<tr style="background-color: #f1f5f9;">
<td colspan="2" style="padding: 6px 8px; font-weight: bold; color: #1e3a8a; font-size: 13px;">Kasse: ${deviceName}</td>
</tr>
`
devItems.forEach((item: any) => {
itemsDetailsText += `\n * ${item.product_name} (${item.category_name}): ${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}`
itemsDetailsHtml += `
<tr>
<td style="padding: 6px 8px; font-size: 13px; color: #334155; font-weight: 500;">
${item.product_name} <span style="font-size: 11px; color: #64748b;">(${item.category_name})</span>
</td>
<td style="padding: 6px 8px; text-align: right; font-size: 13px; font-weight: bold; color: #0f172a;">
${item.base_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} ${item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
</td>
</tr>
`
item.selected_modules?.forEach((mod: any) => {
const qty = mod.quantity || 1
const price = mod.total_price ?? (mod.price * qty)
itemsDetailsText += `\n + ${mod.module_name} ${qty > 1 ? `(x${qty})` : ''}: +${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.`
itemsDetailsHtml += `
<tr>
<td style="padding: 4px 8px 4px 20px; color: #64748b; font-size: 12px;">
+ ${mod.module_name} ${qty > 1 ? `<span style="font-size: 10px; font-weight: 600;">(x${qty})</span>` : ''}
</td>
<td style="padding: 4px 8px; text-align: right; color: #64748b; font-size: 12px;">
+${price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} mtl.
</td>
</tr>
`
})
})
})
return {
text: itemsDetailsText + '\n',
html: itemsDetailsHtml
}
}

View File

@@ -74,26 +74,32 @@ export async function getEndCustomer(id: string): Promise<EndCustomer | null> {
* Legt einen neuen Endkunden für den eingeloggten Partner an.
* partner_id wird serverseitig aus der Session gesetzt (kein Client-Trust).
*/
export async function createEndCustomer(formData: EndCustomerFormData): Promise<EndCustomer> {
export async function createEndCustomer(formData: EndCustomerFormData, companyId?: string): Promise<EndCustomer> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
const { data: dbUser, error: dbUserError } = await supabase
const { data: dbUser } = await supabase
.from('users')
.select('company_id')
.select('role, company_id')
.eq('id', user.id)
.single()
if (dbUserError || !dbUser || !dbUser.company_id) {
const isAdmin = dbUser?.role === 'admin'
const resolvedCompanyId = dbUser?.company_id || companyId
if (!isAdmin && !resolvedCompanyId) {
throw new Error('Kein Unternehmen zugewiesen.')
}
const { data, error } = await supabase
// Admin ohne company_id braucht adminClient (RLS erlaubt kein INSERT ohne passende partner_id)
const client = (!dbUser?.company_id && isAdmin) ? createAdminClient() : supabase
const { data, error } = await client
.from('end_customers')
.insert([{
partner_id: dbUser.company_id,
partner_id: resolvedCompanyId || user.id,
company_name: formData.company_name,
vat_id: formData.vat_id || null,
first_name: formData.first_name || null,
@@ -167,17 +173,17 @@ export async function anonymizeEndCustomer(id: string): Promise<void> {
.from('end_customers')
.update({
company_name: '[GELÖSCHT]',
vat_id: null,
vat_id: '[GELÖSCHT]',
first_name: '[GELÖSCHT]',
last_name: '[GELÖSCHT]',
street: '[GELÖSCHT]',
zip: null,
city: null,
email: null,
bank_iban: null,
bank_bic: null,
bank_name: null,
bank_owner: null,
zip: '[GELÖSCHT]',
city: '[GELÖSCHT]',
email: '[GELÖSCHT]',
bank_iban: '[GELÖSCHT]',
bank_bic: '[GELÖSCHT]',
bank_name: '[GELÖSCHT]',
bank_owner: '[GELÖSCHT]',
is_anonymized: true,
})
.eq('id', id)

View File

@@ -0,0 +1,151 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
export interface LicServerConfig {
base_url: string | null
api_key: string | null
}
/**
* Read LicServer config from the settings table.
* Falls back to environment variables if DB values are empty.
* Returns null config for unauthenticated callers.
*/
export async function getLicServerConfig(): Promise<LicServerConfig> {
const supabase = await createClient()
// Auth guard — never expose config to unauthenticated callers
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { base_url: null, api_key: null }
const { data } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single()
return {
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
}
}
/**
* Save LicServer config. Admin-only — checks role before writing.
*/
export async function saveLicServerConfig(
base_url: string,
api_key: string
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
// Auth + role check
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { success: false, error: 'Nicht angemeldet' }
const { data: userData } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
// Only 'admin' exists in schema — 'superadmin' removed
if (!userData || userData.role !== 'admin') {
return { success: false, error: 'Keine Berechtigung (nur Admins)' }
}
const { error } = await supabase
.from('settings')
.upsert({
id: 'licserver',
licserver_base_url: base_url.trim() || null,
licserver_api_key: api_key.trim() || null,
updated_at: new Date().toISOString(),
})
if (error) return { success: false, error: error.message }
return { success: true }
}
/**
* Test the connection with current config.
* Calls GET /api-v1/partners?pageSize=1 and returns true if 200.
*/
export async function testLicServerConnection(): Promise<{ ok: boolean; message: string }> {
const cfg = await getLicServerConfig()
if (!cfg.base_url || !cfg.api_key) {
return { ok: false, message: 'URL oder API-Key nicht konfiguriert' }
}
try {
const res = await fetch(`${cfg.base_url}/api-v1/partners?pageSize=1`, {
headers: { 'X-Api-Key': cfg.api_key, Accept: 'application/json' },
signal: AbortSignal.timeout(5_000),
})
if (res.ok) return { ok: true, message: `Verbindung OK (HTTP ${res.status})` }
return { ok: false, message: `LicServer antwortete mit HTTP ${res.status}` }
} catch (err: any) {
return { ok: false, message: `Nicht erreichbar: ${err.message}` }
}
}
/**
* System version of getLicServerConfig that bypasses client auth check.
* Used internally by automated background jobs / cron routes.
*/
export async function getLicServerConfigSystem(): Promise<LicServerConfig> {
const admin = createAdminClient()
const { data } = await admin
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single()
return {
base_url: data?.licserver_base_url || process.env.LICSERVER_BASE_URL || null,
api_key: data?.licserver_api_key || process.env.LICSERVER_API_KEY || null,
}
}
export async function lookupLicenseFromLicServer(id: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Nicht autorisiert.')
const cfg = await getLicServerConfigSystem()
if (!cfg.base_url || !cfg.api_key) {
throw new Error('LicServer ist nicht konfiguriert.')
}
const res = await fetch(`${cfg.base_url}/v1/licenses/${encodeURIComponent(id.trim())}`, {
headers: { 'X-Api-Key': cfg.api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5_000),
})
if (res.status === 404) return null
if (!res.ok) {
throw new Error(`Lizenzserver antwortete mit Fehler: HTTP ${res.status}`)
}
const data = await res.json()
return {
licenseKey: data.id,
status: 'active' as const,
product: data.productId || 'Unbekanntes Produkt',
edition: data.serialNumber ? `Seriennummer: ${data.serialNumber}` : 'Standard',
version: '1.0.0',
seats: 1,
customer: data.filename || 'Lizenz-Datei',
contact: '—',
issuedAt: '',
expiresAt: '',
maintenanceUntil: '',
modules: [],
}
}

View File

@@ -1,6 +1,7 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { createHash } from 'crypto'
import { createAdminClient } from '@/lib/supabase/admin'
import { revalidatePath } from 'next/cache'
import { sendMail } from '@/utils/mail'
@@ -10,9 +11,13 @@ import React from 'react'
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products'
import { validateWizardSelections } from '@/lib/actions/validation'
import { getOrderEmailTemplate, getStatusEmailTemplate, buildEmailItemsSection } from '@/lib/actions/email-templates'
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
/**
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
* Format: BE-YYYY-NNNNN (z.B. BE-2026-84731)
@@ -25,18 +30,12 @@ function generateOrderNumber(): string {
}
/**
* Erstellt einen einfachen deterministischen Hash aus dem Bestell-Snapshot.
* Erstellt einen SHA-256 Hash aus dem Bestell-Snapshot.
* Wird für den Idempotenz-Guard verwendet (verhindert Doppelbestellungen).
*/
function hashOrderSnapshot(snapshot: object): string {
const str = JSON.stringify(snapshot)
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // 32-bit integer
}
return Math.abs(hash).toString(36)
return createHash('sha256').update(str).digest('hex')
}
// ─── submitOrder ──────────────────────────────────────────────────────────────
@@ -83,88 +82,23 @@ export async function submitOrder(params: {
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
if (!hasMetRequirement) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// Product-level constraints validation
const selectedProductIds = Object.values(selections)
.map(s => s.productId)
.filter((id): id is string => !!id)
for (const prodId of selectedProductIds) {
const prod = dbProducts.find(p => p.id === prodId)
if (!prod) continue
// Product requirements check
if (prod.requirements && prod.requirements.length > 0) {
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
if (!hasMetRequirement) {
const reqNames = prod.requirements
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
.join(' oder ')
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
}
}
// Product exclusions check
if (prod.exclusions && prod.exclusions.length > 0) {
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
if (conflicting.length > 0) {
const conflictingNames = conflicting
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
.join(', ')
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
}
}
}
validateWizardSelections(selections, dbProducts, dbCategories)
// 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories, billingInterval, moduleQuantities, lastLicenseDate)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 5 Minuten existiert
const orderHash = hashOrderSnapshot(orderSnapshot)
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString()
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString()
const { data: existingOrder } = await supabase
.from('orders')
.select('*')
.eq('user_id', user.id)
.eq('order_hash', orderHash)
.gte('created_at', thirtySecondsAgo)
.gte('created_at', fiveMinutesAgo)
.maybeSingle()
if (existingOrder) {
@@ -324,42 +258,23 @@ export async function submitOrder(params: {
`
}
const itemsSection = buildEmailItemsSection(items)
const emailTemplate = getOrderEmailTemplate({
orderNumber,
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false)
await sendMail({
to: user.email,
subject: `Anfragebestätigung ${orderNumber}`,
text: `Hallo,\n\nvielen Dank für Ihre Anfrage bei CASPOS Shop!\n\nAnfragedetails:\n- Anfrage-Nummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.\n\nPDF herunterladen: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Vielen Dank für Ihre Anfrage!</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">wir freuen uns sehr über Ihre Anfrage bei CASPOS Shop. Ihre Anfrage wurde erfolgreich entgegengenommen und wird nun verarbeitet.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Anfrage-Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
<td style="padding: 4px 0;">${orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
</tr>
${totalDetailsHtml}
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download" style="display: inline-block; background-color: #3b82f6; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: bold; font-size: 14px;">📄 PDF herunterladen</a>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
</div>
`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${orderNumber}.pdf`,
@@ -383,7 +298,7 @@ export async function submitOrder(params: {
export async function updateOrderStatus(
orderId: string,
newStatus: 'pending' | 'active' | 'completed' | 'cancelled'
newStatus: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
) {
const admin = createAdminClient()
@@ -428,40 +343,18 @@ export async function updateOrderStatus(
active: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Storniert',
rejected: 'Abgelehnt',
}
const oldLabel = statusLabelMap[oldStatus] || oldStatus
const newLabel = statusLabelMap[newStatus] || newStatus
const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel)
await sendMail({
to: user.email,
subject: `Statusänderung Ihrer Bestellung ${orderNumber}`,
text: `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Status Ihrer Bestellung hat sich geändert</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">der Status Ihrer Bestellung mit der Nummer <strong>${orderNumber}</strong> wurde aktualisiert.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Bestellnummer:</td>
<td style="padding: 4px 0;">${orderNumber}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Alter Status:</td>
<td style="padding: 4px 0; text-decoration: line-through; color: #94a3b8;">${oldLabel}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Neuer Status:</td>
<td style="padding: 4px 0; font-weight: bold; color: #3b82f6;">${newLabel}</td>
</tr>
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Viele Grüße,<br>Ihr CASPOS Shop-Team</p>
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 24px 0;">
<p style="color: #94a3b8; font-size: 12px; text-align: center;">Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.</p>
</div>
`
subject: `Statusänderung Ihrer Anfrage ${orderNumber}`,
text: statusEmail.text,
html: statusEmail.html
})
}
} catch (mailError) {
@@ -543,72 +436,7 @@ export async function updateOrder(
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
if (!hasMetRequirement) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// Product-level constraints validation
const selectedProductIds = Object.values(selections)
.map(s => s.productId)
.filter((id): id is string => !!id)
for (const prodId of selectedProductIds) {
const prod = dbProducts.find(p => p.id === prodId)
if (!prod) continue
// Product requirements check
if (prod.requirements && prod.requirements.length > 0) {
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
if (!hasMetRequirement) {
const reqNames = prod.requirements
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
.join(' oder ')
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
}
}
// Product exclusions check
if (prod.exclusions && prod.exclusions.length > 0) {
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
if (conflicting.length > 0) {
const conflictingNames = conflicting
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
.join(', ')
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
}
}
}
validateWizardSelections(selections, dbProducts, dbCategories)
// 1. Snapshots aufbauen
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
@@ -755,40 +583,23 @@ export async function updateOrder(
`
}
const itemsSection = buildEmailItemsSection(items)
const emailTemplate = getOrderEmailTemplate({
orderNumber: order.order_number,
formattedDate,
customerCompanyName: customerSnapshot.company_name,
totalDetailsText,
totalDetailsHtml,
itemsDetailsText: itemsSection.text,
itemsDetailsHtml: itemsSection.html
}, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true)
await sendMail({
to: orderUserEmail,
subject: `Anfrageänderung ${order.order_number}`,
text: `Hallo,\n\nihre Anfrage wurde erfolgreich aktualisiert!\n\nNeue Anfragedetails:\n- Anfrage-Nummer: ${order.order_number}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfrage als PDF-Dokument.\n\nPDF herunterladen: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="color: #0f172a; margin-bottom: 16px;">Ihre Anfrage wurde aktualisiert!</h2>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Hallo,</p>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Ihr Anfrage mit der Nummer ${order.order_number} wurde erfolgreich aktualisiert.</p>
<div style="background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 24px 0;">
<h3 style="color: #0f172a; margin-top: 0; margin-bottom: 12px;">Neue Anfrage-Details</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px; color: #475569;">
<tr>
<td style="padding: 4px 0; font-weight: bold; width: 140px;">Anfrage-Nummer:</td>
<td style="padding: 4px 0;">${order.order_number}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Datum:</td>
<td style="padding: 4px 0;">${formattedDate}</td>
</tr>
<tr>
<td style="padding: 4px 0; font-weight: bold;">Endkunde:</td>
<td style="padding: 4px 0;">${customerSnapshot.company_name}</td>
</tr>
${totalDetailsHtml}
</table>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5;">Im Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfragebestätigung als PDF-Dokument.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download" style="display: inline-block; background-color: #3b82f6; color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: bold; font-size: 14px;">📄 PDF herunterladen</a>
</div>
<p style="color: #475569; font-size: 16px; line-height: 1.5; margin-top: 24px;">Mit freundlichen Grüßen,<br>Ihr CASPOS Shop-Team</p>
</div>
`,
text: emailTemplate.text,
html: emailTemplate.html,
attachments: [
{
filename: `Anfragebestaetigung_${order.order_number}.pdf`,

View File

@@ -206,3 +206,61 @@ export async function transferModules(
revalidatePath('/admin/products')
revalidatePath('/order')
}
export async function addProductDependency(
productId: string,
type: 'requirements' | 'exclusions',
dependencyId: string
) {
const supabase = await createClient()
const { data: product, error: fetchError } = await supabase
.from('products')
.select('requirements, exclusions')
.eq('id', productId)
.single()
if (fetchError) throw fetchError
const currentDeps = (product[type] || []) as string[]
if (!currentDeps.includes(dependencyId)) {
const updatedDeps = [...currentDeps, dependencyId]
const { error: updateError } = await supabase
.from('products')
.update({ [type]: updatedDeps })
.eq('id', productId)
if (updateError) throw updateError
}
revalidatePath('/admin/products')
revalidatePath('/order')
}
export async function removeProductDependency(
productId: string,
type: 'requirements' | 'exclusions',
dependencyId: string
) {
const supabase = await createClient()
const { data: product, error: fetchError } = await supabase
.from('products')
.select('requirements, exclusions')
.eq('id', productId)
.single()
if (fetchError) throw fetchError
const currentDeps = (product[type] || []) as string[]
if (currentDeps.includes(dependencyId)) {
const updatedDeps = currentDeps.filter(id => id !== dependencyId)
const { error: updateError } = await supabase
.from('products')
.update({ [type]: updatedDeps })
.eq('id', productId)
if (updateError) throw updateError
}
revalidatePath('/admin/products')
revalidatePath('/order')
}

View File

@@ -0,0 +1,104 @@
import { WizardSelections, Product, Category } from '@/lib/types'
export function validateWizardSelections(
selections: WizardSelections,
dbProducts: Product[],
dbCategories: Category[]
): void {
for (const cat of dbCategories) {
const sel = selections[cat.id]
// --- is_required: Pflichtauswahl prüfen ---
const hasSelection = sel && (
(cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) ||
(!cat.allow_multiselect && sel.productId)
)
if (cat.is_required && !hasSelection) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel) {
// --- allow_multiselect = false: keine doppelten IDs erlaubt ---
if (!cat.allow_multiselect && sel.productIds && sel.productIds.length > 1) {
throw new Error(
`Die Kategorie "${cat.name}" erlaubt keine Mehrfachauswahl. Bitte nur ein Produkt wählen.`
)
}
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds) {
sel.productIds.forEach(pId => {
const p = dbProducts.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel.productId) {
const p = dbProducts.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
for (const prod of selectedProds) {
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) continue
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId))
if (!hasMetRequirement) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
}
// --- Product-level constraints validation ---
const selectedProductIds: string[] = []
Object.values(selections).forEach(s => {
if (s.productIds && s.productIds.length > 0) {
s.productIds.forEach(id => {
if (id) selectedProductIds.push(id)
})
} else if (s.productId) {
selectedProductIds.push(s.productId)
}
})
for (const prodId of selectedProductIds) {
const prod = dbProducts.find(p => p.id === prodId)
if (!prod) continue
// Product requirements check
if (prod.requirements && prod.requirements.length > 0) {
const hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId))
if (!hasMetRequirement) {
const reqNames = prod.requirements
.map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId)
.join(' oder ')
throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`)
}
}
// Product exclusions check
if (prod.exclusions && prod.exclusions.length > 0) {
const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId))
if (conflicting.length > 0) {
const conflictingNames = conflicting
.map(exId => dbProducts.find(p => p.id === exId)?.name || exId)
.join(', ')
throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`)
}
}
}
}

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { splitCart, CartItem } from './checkout-split';
describe('Checkout Split Logic', () => {
it('should place purchase items only into purchase group', () => {
const cart: CartItem[] = [
{ billingInterval: 'one_time', selections: {} },
{ billingType: 'purchase', selections: {} }
];
const { purchaseItems, subscriptionItems } = splitCart(cart);
expect(purchaseItems.length).toBe(2);
expect(subscriptionItems.length).toBe(0);
});
it('should place subscription items only into subscription group', () => {
const cart: CartItem[] = [
{ billingInterval: 'monthly', selections: {} },
{ billingType: 'subscription', selections: {} }
];
const { purchaseItems, subscriptionItems } = splitCart(cart);
expect(purchaseItems.length).toBe(0);
expect(subscriptionItems.length).toBe(2);
});
it('should split mixed cart into separate purchase and subscription groups', () => {
const cart: CartItem[] = [
{ billingInterval: 'one_time', selections: {} },
{ billingInterval: 'monthly', selections: {} },
{ billingType: 'purchase', selections: {} },
{ billingType: 'subscription', selections: {} }
];
const { purchaseItems, subscriptionItems } = splitCart(cart);
expect(purchaseItems.length).toBe(2);
expect(subscriptionItems.length).toBe(2);
});
});

View File

@@ -0,0 +1,17 @@
export interface CartItem {
id?: string;
billingInterval?: 'one_time' | 'monthly';
billingType?: 'purchase' | 'subscription';
selections: any;
moduleQuantities?: Record<string, number>;
}
export function splitCart(items: CartItem[]) {
const purchaseItems = items.filter(
item => item.billingInterval === 'one_time' || item.billingType === 'purchase'
);
const subscriptionItems = items.filter(
item => item.billingInterval === 'monthly' || item.billingType === 'subscription'
);
return { purchaseItems, subscriptionItems };
}

View File

@@ -99,40 +99,59 @@ export function buildOrderSnapshot(
for (const cat of categories) {
const sel: CategorySelection | undefined = selections[cat.id]
if (!sel?.productId) continue
if (!sel) continue
const prod = products.find((p) => p.id === sel.productId)
if (!prod) continue
dominantTaxRate = prod.tax_rate
const selectedModules = sel.moduleIds
.map((modId) => prod.modules?.find((m) => m.id === modId))
.filter((m): m is NonNullable<typeof m> => !!m)
.map((mod) => {
const qty = moduleQuantities?.[mod.id] || 1
return {
module_id: mod.id,
module_name: mod.name,
price: mod.price,
quantity: qty,
total_price: mod.price * qty,
}
const selectedProds: Product[] = []
if (cat.allow_multiselect && sel.productIds && sel.productIds.length > 0) {
sel.productIds.forEach(pId => {
const p = products.find(prod => prod.id === pId)
if (p) selectedProds.push(p)
})
} else if (sel.productId) {
const p = products.find(prod => prod.id === sel.productId)
if (p) selectedProds.push(p)
}
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
const itemTotal = prod.base_price + moduleTotal
subtotal += itemTotal
if (selectedProds.length === 0) continue
items.push({
category_id: cat.id,
category_name: cat.name,
product_id: prod.id,
product_name: prod.name,
base_price: prod.base_price,
billing_interval: prod.billing_interval,
selected_modules: selectedModules,
item_total: itemTotal,
// Apply free items limit: sort selected products by base_price ascending so that the cheapest are free
const sortedProds = [...selectedProds].sort((a, b) => a.base_price - b.base_price)
const freeLimit = cat.allow_multiselect ? cat.free_items_limit : 0
sortedProds.forEach((prod, sortedIndex) => {
dominantTaxRate = prod.tax_rate
const selectedModules = sel.moduleIds
.map((modId) => prod.modules?.find((m) => m.id === modId))
.filter((m): m is NonNullable<typeof m> => !!m)
.map((mod) => {
const qty = moduleQuantities?.[mod.id] || 1
return {
module_id: mod.id,
module_name: mod.name,
price: mod.price,
quantity: qty,
total_price: mod.price * qty,
}
})
const moduleTotal = selectedModules.reduce((acc, m) => acc + (m.total_price || m.price), 0)
// If this product falls under the free limit, set its base price to 0
const actualBasePrice = (sortedIndex < freeLimit) ? 0 : prod.base_price
const itemTotal = actualBasePrice + moduleTotal
subtotal += itemTotal
items.push({
category_id: cat.id,
category_name: cat.name,
product_id: prod.id,
product_name: prod.name + (actualBasePrice === 0 ? "" : ""),
base_price: actualBasePrice,
billing_interval: prod.billing_interval,
selected_modules: selectedModules,
item_total: itemTotal,
})
})
}
@@ -164,10 +183,14 @@ export function buildOrderSnapshot(
multiplier = 0.90
}
// Multiply only one_time item totals by multiplier
// Multiply only one_time item totals by multiplier if product allows it
items.forEach(item => {
if (item.billing_interval === 'one_time') {
item.item_total = item.item_total * multiplier
const prod = products.find(p => p.id === item.product_id);
const allowDiscount = prod ? prod.allow_update_discount !== false : true;
if (allowDiscount) {
item.item_total = item.item_total * multiplier;
}
}
})
subtotal = items.reduce((acc, item) => acc + item.item_total, 0)

View File

@@ -1,11 +1,15 @@
import { createClient } from '@supabase/supabase-js'
export function createAdminClient() {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU'
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
throw new Error('SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment variables.');
}
return createClient(
supabaseUrl!,
supabaseUrl,
serviceRoleKey,
{
auth: {
@@ -15,3 +19,4 @@ export function createAdminClient() {
}
)
}

View File

@@ -17,6 +17,7 @@ export type Product = {
show_in_abo?: boolean
requirements?: string[]
exclusions?: string[]
allow_update_discount?: boolean
}
export type Category = {
@@ -26,6 +27,12 @@ export type Category = {
icon?: string | null
sort_order: number
is_required: boolean
allow_multiselect: boolean
free_items_limit: number
preselect: boolean
show_in_kauf?: boolean
show_in_abo?: boolean
resets_others: boolean
created_at: string
}
@@ -79,7 +86,8 @@ export type EndCustomer = {
// ─── Wizard-Zustand (flüchtig, im RAM) ───────────────────────────────────────
export type CategorySelection = {
productId: string | null
productId: string | null // for backwards compatibility & single select
productIds?: string[] // for multi-select categories
moduleIds: string[]
}
@@ -127,6 +135,7 @@ export type OrderItem = {
billing_interval: BillingInterval
selected_modules: OrderModuleSnapshot[]
item_total: number
device_name?: string
}
/**
@@ -157,7 +166,7 @@ export type Order = {
order_data: OrderSnapshot
total_price: number
pdf_url: string | null
status: 'pending' | 'active' | 'completed' | 'cancelled'
status: 'pending' | 'active' | 'completed' | 'cancelled' | 'rejected'
created_at: string
}

View File

@@ -0,0 +1,88 @@
/**
* TypeScript types generated from the CASPOS LicServer OpenAPI spec v1.
* Source: /api-v1/partners endpoints
*/
export interface PartnerDto {
id: string // UUID
name: string | null
email: string | null
erpId: string | null
}
export interface PartnerDtoPagedResult {
page: number
pageSize: number
totalCount: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
items: PartnerDto[] | null
}
/** Standard RFC 7807 problem response from LicServer */
export interface LicServerProblemDetails {
type?: string | null
title?: string | null
status?: number | null
detail?: string | null
instance?: string | null
[key: string]: unknown
}
export interface CustomerDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
}
export interface CustomerDtoPagedResult {
page: number
pageSize: number
totalCount: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
items: CustomerDto[] | null
}
export interface LocationDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
countryCode: string | null
postalCode: string | null
city: string | null
taxNumber: string | null
vatIdNumber: string | null
}
export interface CustomerDetailsDto {
id: string // UUID
name: string | null
street: string | null
houseNumber: string | null
houseNumberAddon: string | null
zipCode: string | null
city: string | null
countryCode: string | null
email: string | null
partnerId: string | null
erpId: string | null
partnerName: string | null
taxNumber: string | null
vatIdNumber: string | null
notes: string | null
locations: LocationDto[] | null
}

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { handleBaseProductChange, handleModuleToggle } from './wizard-state';
import { Product, ProductModule, Category, WizardSelections } from './types';
describe('Wizard State Management', () => {
const mockCategories: Category[] = [
{
id: 'cat-1',
name: 'Kasse',
sort_order: 1,
is_required: true,
allow_multiselect: false,
free_items_limit: 0,
preselect: false,
resets_others: false,
created_at: '',
},
{
id: 'cat-2',
name: 'Backoffice',
sort_order: 2,
is_required: false,
allow_multiselect: false, // Radio behavior
free_items_limit: 0,
preselect: false,
resets_others: false,
created_at: '',
}
];
const mockModules: ProductModule[] = [
{
id: 'mod-cloud',
product_id: 'prod-gastro',
name: 'POSCloud',
price: 10,
requirements: [],
exclusions: [],
created_at: '',
},
{
id: 'mod-office-1',
product_id: 'prod-gastro',
name: 'Office Pro',
price: 20,
requirements: [],
exclusions: [],
created_at: '',
},
{
id: 'mod-office-2',
product_id: 'prod-gastro',
name: 'Office Light',
price: 5,
requirements: [],
exclusions: [],
created_at: '',
}
];
it('should automatically remove excluded modules when switching base product', () => {
const currentSelections: WizardSelections = {
'cat-2': {
productId: null,
moduleIds: ['mod-cloud'] // User has POSCloud selected
}
};
// Exclusion rule: 'prod-small' excludes 'mod-cloud'
const mockExclusions = [
{
product_id: 'prod-small',
excluded_module_id: 'mod-cloud'
}
];
const result = handleBaseProductChange(
'prod-small',
currentSelections,
mockModules,
[],
mockExclusions
);
// POSCloud should be removed
expect(result['cat-2'].moduleIds).not.toContain('mod-cloud');
});
it('should respect allow_multiselect = false and only allow one selection in that category', () => {
let selections: WizardSelections = {
'cat-2': {
productId: null,
moduleIds: ['mod-office-1']
}
};
// Toggle on 'mod-office-2' in a single-select category
selections = handleModuleToggle(
'mod-office-2',
'cat-2',
selections,
mockCategories,
mockModules
);
// Should only contain 'mod-office-2'
expect(selections['cat-2'].moduleIds).toContain('mod-office-2');
expect(selections['cat-2'].moduleIds).not.toContain('mod-office-1');
});
});

39
shop/lib/wizard-state.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Product, ProductModule, Category, WizardSelections } from './types';
/**
* Handle toggling of a module selection.
* Handles category-specific allow_multiselect rules.
*/
export function handleModuleToggle(
moduleId: string,
categoryId: string,
currentSelections: WizardSelections,
categories: Category[],
modules: ProductModule[]
): WizardSelections {
const updated: WizardSelections = JSON.parse(JSON.stringify(currentSelections));
const category = categories.find(c => c.id === categoryId);
const allowMultiple = category ? category.allow_multiselect : true;
if (!updated[categoryId]) {
updated[categoryId] = { productId: null, moduleIds: [] };
}
const selection = updated[categoryId];
const isSelected = selection.moduleIds.includes(moduleId);
if (isSelected) {
// Toggle off
selection.moduleIds = selection.moduleIds.filter(id => id !== moduleId);
} else {
// Toggle on
if (!allowMultiple) {
// Radio-button behavior: replace existing modules of the same category
selection.moduleIds = [moduleId];
} else {
selection.moduleIds.push(moduleId);
}
}
return updated;
}

1602
shop/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -51,6 +52,7 @@
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
"typescript": "^5",
"vitest": "^1.6.0"
}
}

64
shop/scratch_test.js Normal file
View File

@@ -0,0 +1,64 @@
const { createClient } = require('@supabase/supabase-js');
const fs = require('fs');
const path = require('path');
// Read .env.local
const envPath = path.join(__dirname, '.env.local');
const envContent = fs.readFileSync(envPath, 'utf8');
const env = {};
envContent.split('\n').forEach(line => {
const parts = line.split('=');
if (parts.length >= 2) {
env[parts[0].trim()] = parts.slice(1).join('=').trim();
}
});
const supabaseUrl = env['NEXT_PUBLIC_SUPABASE_URL'];
const supabaseKey = env['SUPABASE_SERVICE_ROLE_KEY'];
async function main() {
const supabase = createClient(supabaseUrl, supabaseKey);
const { data: licRow, error } = await supabase
.from('settings')
.select('licserver_base_url, licserver_api_key')
.eq('id', 'licserver')
.single();
if (error || !licRow) {
console.error("Failed to load licserver config:", error);
return;
}
const base = 'http://192.168.178.174:9981'; // Target API port 9981
const apiKey = licRow.licserver_api_key;
console.log("Using API Base:", base);
console.log("API Key found:", apiKey ? "Yes (length " + apiKey.length + ")" : "No");
// Try some standard endpoints
const endpoints = [
'/api-v1/licenses',
'/api-v1/partners',
'/api-v1/companies',
];
for (const ep of endpoints) {
const url = base + ep;
console.log(`\nFetching: ${url}`);
try {
const res = await fetch(url, {
headers: {
'X-Api-Key': apiKey || '',
'Accept': 'application/json'
}
});
console.log(`Status: ${res.status} ${res.statusText}`);
const text = await res.text();
console.log(`Response (first 500 chars):`, text.substring(0, 500));
} catch (e) {
console.error(`Error fetching ${url}:`, e.message);
}
}
}
main();

View File

@@ -24,28 +24,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
);
-- 4. Recreate RLS policies for licenses (since partner_id now matches company_id)
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
)
);
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
)
);
-- 4. Licenses policy recreation is omitted since licenses table is dropped.
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -33,31 +33,7 @@ CREATE POLICY "Partner sehen nur eigene Endkunden" ON public.end_customers
OR partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
);
-- 4. Recreate licenses SELECT RLS policy to allow admin bypass
DROP POLICY IF EXISTS "Partner sehen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner sehen Lizenzen eigener Kunden" ON public.licenses
FOR SELECT
USING (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
OR EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
)
);
-- 5. Recreate licenses INSERT RLS policy to allow admin bypass
DROP POLICY IF EXISTS "Partner erstellen Lizenzen eigener Kunden" ON public.licenses;
CREATE POLICY "Partner erstellen Lizenzen eigener Kunden" ON public.licenses
FOR INSERT
WITH CHECK (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
OR EXISTS (
SELECT 1 FROM public.end_customers c
WHERE c.id = licenses.end_customer_id
AND c.partner_id = (SELECT company_id FROM public.users WHERE id = auth.uid())
)
);
-- 4 & 5. Licenses policy updates are omitted since licenses table is dropped.
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,98 @@
-- Migration: Fix critical RLS security issues
-- 1. Restrict products/modules/categories CRUD to admin role only
-- 2. Restrict settings table to admin role only
-- 3. Remove USING(true) wildcard policies on companies/partners
-- ═══════════════════════════════════════════════════════════════════════════════
-- 1. Products: Replace overly permissive CRUD policies with admin-only
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Allow authenticated insert on products" ON public.products;
DROP POLICY IF EXISTS "Allow authenticated update on products" ON public.products;
DROP POLICY IF EXISTS "Allow authenticated delete on products" ON public.products;
CREATE POLICY "Admin insert products" ON public.products FOR INSERT
WITH CHECK ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin update products" ON public.products FOR UPDATE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin delete products" ON public.products FOR DELETE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
-- ═══════════════════════════════════════════════════════════════════════════════
-- 2. Product Modules: Same fix
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Allow authenticated insert on product_modules" ON public.product_modules;
DROP POLICY IF EXISTS "Allow authenticated update on product_modules" ON public.product_modules;
DROP POLICY IF EXISTS "Allow authenticated delete on product_modules" ON public.product_modules;
CREATE POLICY "Admin insert product_modules" ON public.product_modules FOR INSERT
WITH CHECK ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin update product_modules" ON public.product_modules FOR UPDATE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin delete product_modules" ON public.product_modules FOR DELETE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
-- ═══════════════════════════════════════════════════════════════════════════════
-- 3. Categories: Same fix
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Allow authenticated insert on categories" ON public.categories;
DROP POLICY IF EXISTS "Allow authenticated update on categories" ON public.categories;
DROP POLICY IF EXISTS "Allow authenticated delete on categories" ON public.categories;
CREATE POLICY "Admin insert categories" ON public.categories FOR INSERT
WITH CHECK ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin update categories" ON public.categories FOR UPDATE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin delete categories" ON public.categories FOR DELETE
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
-- ═══════════════════════════════════════════════════════════════════════════════
-- 4. Settings: Restrict to admin-only (contains SMTP credentials!)
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Allow authenticated read on settings" ON public.settings;
DROP POLICY IF EXISTS "Allow authenticated write on settings" ON public.settings;
CREATE POLICY "Admin read settings" ON public.settings FOR SELECT
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
CREATE POLICY "Admin write settings" ON public.settings FOR ALL
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin')
WITH CHECK ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
-- ═══════════════════════════════════════════════════════════════════════════════
-- 5. Companies: Remove USING(true) wildcard, service_role bypasses RLS anyway
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Service role kann alles Unternehmen" ON public.companies;
-- Admin-only write access for companies
CREATE POLICY "Admin manage companies" ON public.companies FOR ALL
USING ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin')
WITH CHECK ((SELECT role FROM public.users WHERE id = auth.uid()) = 'admin');
-- ═══════════════════════════════════════════════════════════════════════════════
-- 6. Orders: Add INSERT policy with company_id check
-- ═══════════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Users can create their own orders" ON public.orders;
CREATE POLICY "Users can create their own orders" ON public.orders FOR INSERT
WITH CHECK (
auth.uid() = user_id
AND (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
OR (SELECT company_id FROM public.users WHERE id = auth.uid()) IS NOT NULL
)
);
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,117 @@
-- Migration: Remove unused pos_modules and licenses tables and reference functions
-- 1. Drop the tables (cascade to clean up RLS policies automatically)
DROP TABLE IF EXISTS public.licenses CASCADE;
DROP TABLE IF EXISTS public.pos_modules CASCADE;
-- 2. Update database integrity function to remove pos_modules and licenses checks
CREATE OR REPLACE FUNCTION public.check_database_integrity()
RETURNS jsonb
SECURITY DEFINER
AS $$
DECLARE
tables_to_check text[] := ARRAY[
'users',
'settings',
'companies',
'categories',
'products',
'product_modules',
'orders',
'profiles',
'end_customers'
];
t text;
t_exists boolean;
t_count bigint;
tables_result jsonb := jsonb_build_object();
result jsonb;
BEGIN
FOREACH t IN ARRAY tables_to_check LOOP
SELECT EXISTS (
SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = t
) INTO t_exists;
t_count := 0;
IF t_exists THEN
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO t_count;
END IF;
tables_result := tables_result || jsonb_build_object(t, jsonb_build_object('exists', t_exists, 'count', t_count));
END LOOP;
result := jsonb_build_object(
'tables', tables_result,
'errors', jsonb_build_object(
'foreign_keys', 0
)
);
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- 3. Update database repair schema function to remove creation of pos_modules and licenses
CREATE OR REPLACE FUNCTION public.repair_database_schema()
RETURNS jsonb
SECURITY DEFINER
AS $$
DECLARE
repaired_tables text[] := '{}';
result jsonb;
BEGIN
-- 1. profiles table
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'profiles') THEN
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
first_name TEXT,
last_name TEXT,
company_name TEXT,
vat_id TEXT,
address TEXT,
city TEXT,
zip_code TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
repaired_tables := array_append(repaired_tables, 'profiles');
END IF;
-- 2. end_customers table
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'end_customers') THEN
CREATE TABLE public.end_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
vat_id TEXT,
first_name TEXT,
last_name TEXT,
street TEXT,
zip TEXT,
city TEXT,
is_anonymized BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
repaired_tables := array_append(repaired_tables, 'end_customers');
END IF;
-- Enable RLS
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.end_customers ENABLE ROW LEVEL SECURITY;
-- Recreate RLS Policies
DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles;
CREATE POLICY "Users can view their own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles;
CREATE POLICY "Users can update their own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
DROP POLICY IF EXISTS "Users can insert their own profile" ON public.profiles;
CREATE POLICY "Users can insert their own profile" ON public.profiles FOR INSERT WITH CHECK (auth.uid() = id);
result := jsonb_build_object(
'repaired_tables', repaired_tables
);
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Reload Schema Cache
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,3 @@
-- Add multi-select and free items limit to categories table
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS allow_multiselect BOOLEAN DEFAULT false NOT NULL;
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS free_items_limit INTEGER DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,2 @@
-- Add preselect column to categories table
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS preselect BOOLEAN DEFAULT true NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Add show_in_kauf and show_in_abo columns to categories table
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS show_in_kauf BOOLEAN DEFAULT true NOT NULL;
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS show_in_abo BOOLEAN DEFAULT true NOT NULL;

View File

@@ -0,0 +1,2 @@
-- Add resets_others column to categories table
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS resets_others BOOLEAN DEFAULT false NOT NULL;

View File

@@ -0,0 +1,107 @@
-- Migration: Consolidated Schema for CASPOS
-- Purpose: Complete definition of core webshop structures, global rules, and order split support.
CREATE TABLE IF NOT EXISTS public.companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
street TEXT,
zip TEXT,
city TEXT,
email TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'partner' CHECK (role IN ('partner', 'admin')),
company_id UUID REFERENCES public.companies(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.end_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
company_name TEXT,
first_name TEXT,
last_name TEXT,
street TEXT,
zip TEXT,
city TEXT,
email TEXT,
bank_iban TEXT,
bank_bic TEXT,
bank_name TEXT,
bank_owner TEXT,
is_anonymized BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
is_required BOOLEAN DEFAULT false NOT NULL,
allow_multiple BOOLEAN DEFAULT false NOT NULL,
sort_order INTEGER DEFAULT 0 NOT NULL,
show_in_branches BOOLEAN DEFAULT true NOT NULL,
preselect BOOLEAN DEFAULT false NOT NULL,
resets_others BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
base_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
tax_rate DECIMAL(5,2) DEFAULT 19.00 NOT NULL,
billing_interval TEXT DEFAULT 'monthly' NOT NULL CHECK (billing_interval IN ('one_time', 'monthly')),
show_in_branches BOOLEAN DEFAULT true NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
category_id UUID REFERENCES public.categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
has_quantity BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.global_inclusions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trigger_product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
included_module_id UUID NOT NULL REFERENCES public.modules(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (trigger_product_id, included_module_id)
);
CREATE TABLE IF NOT EXISTS public.global_exclusions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
excluded_product_id UUID REFERENCES public.products(id) ON DELETE CASCADE,
excluded_module_id UUID REFERENCES public.modules(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (
(excluded_product_id IS NOT NULL AND excluded_module_id IS NULL) OR
(excluded_product_id IS NULL AND excluded_module_id IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS public.orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
company_id UUID REFERENCES public.companies(id) ON DELETE RESTRICT,
end_customer_id UUID REFERENCES public.end_customers(id) ON DELETE RESTRICT,
order_number TEXT NOT NULL UNIQUE,
order_hash TEXT,
type TEXT NOT NULL CHECK (type IN ('purchase', 'subscription')),
payment_method TEXT NOT NULL,
total_price DECIMAL(10,2) DEFAULT 0.00 NOT NULL,
customer_snapshot JSONB NOT NULL,
order_snapshot JSONB NOT NULL,
pdf_url TEXT,
status TEXT DEFAULT 'pending' NOT NULL CHECK (status IN ('pending', 'active', 'completed', 'cancelled')),
created_at TIMESTAMPTZ DEFAULT now()
);

View File

@@ -0,0 +1,42 @@
-- Migration: Secure User Roles from Self-Escalation
-- Purpose: Prevent users from updating their own roles to 'admin' using RLS / Triggers.
-- Ensure RLS is enabled on users
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Policy to allow users to view their own records
CREATE POLICY select_own_user ON public.users
FOR SELECT
TO authenticated
USING (auth.uid() = id);
-- Policy to allow admins to view all users
CREATE POLICY select_all_users_for_admin ON public.users
FOR SELECT
TO authenticated
USING (
(SELECT role FROM public.users WHERE id = auth.uid()) = 'admin'
);
-- Trigger to prevent any role updates to 'admin' from unauthorized users
CREATE OR REPLACE FUNCTION check_user_role_escalation()
RETURNS TRIGGER AS $$
BEGIN
-- Only allow changes to the role column if executed by the service_role
IF (TG_OP = 'UPDATE' AND OLD.role IS DISTINCT FROM NEW.role) OR (TG_OP = 'INSERT') THEN
IF current_setting('role', true) <> 'service_role' THEN
-- Partners cannot upgrade themselves or others to admin
IF NEW.role = 'admin' THEN
RAISE EXCEPTION 'Unberechtigtes Rollen-Upgrade verweigert.';
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS enforce_role_protection ON public.users;
CREATE TRIGGER enforce_role_protection
BEFORE INSERT OR UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION check_user_role_escalation();

View File

@@ -0,0 +1,22 @@
-- Migration: Fix Users RLS Recursion and Admin Lockout
-- Purpose: Implement a security definer function to check admin status, avoiding infinite recursion on public.users.
-- 1. Create helper function to check admin role bypassing RLS (SECURITY DEFINER)
CREATE OR REPLACE FUNCTION public.is_admin(user_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.users
WHERE id = user_id AND role = 'admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 2. Drop the recursive policy on public.users
DROP POLICY IF EXISTS select_all_users_for_admin ON public.users;
-- 3. Re-create the policy using the non-recursive helper function
CREATE POLICY select_all_users_for_admin ON public.users
FOR SELECT
TO authenticated
USING (public.is_admin(auth.uid()));

View File

@@ -0,0 +1,6 @@
-- Migration: Add missing payment_method column to orders table
-- Purpose: Add payment_method column to public.orders and reload Schema Cache.
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS payment_method TEXT;
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,7 @@
-- Migration: Add missing type and payment_method columns to orders table
-- Purpose: Ensure physical columns type and payment_method exist on public.orders for checkout split support.
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'purchase' CHECK (type IN ('purchase', 'subscription'));
ALTER TABLE public.orders ADD COLUMN IF NOT EXISTS payment_method TEXT;
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,6 @@
-- Migration: Add allow_update_discount to categories
-- Purpose: Add a boolean flag to categories to control if their products are eligible for the update discount.
ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS allow_update_discount BOOLEAN DEFAULT true NOT NULL;
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,7 @@
-- Migration: Move allow_update_discount from categories to products
-- Purpose: Control update discount eligibility on product level rather than category level.
ALTER TABLE public.categories DROP COLUMN IF EXISTS allow_update_discount;
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS allow_update_discount BOOLEAN DEFAULT true NOT NULL;
NOTIFY pgrst, 'reload schema';

View File

@@ -0,0 +1,11 @@
-- Migration: Add LicServer configuration columns to settings table
ALTER TABLE public.settings
ADD COLUMN IF NOT EXISTS licserver_base_url TEXT,
ADD COLUMN IF NOT EXISTS licserver_api_key TEXT;
-- Ensure the licserver config row exists (upsert with empty defaults)
INSERT INTO public.settings (id, licserver_base_url, licserver_api_key)
VALUES ('licserver', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
NOTIFY pgrst, 'reload schema';

Some files were not shown because too many files have changed in this diff Show More